システム概要
Playable! Playthrough Tester は、記録した人間の操作をお手本にしてゲームのプレーを自動で行うテストツールです。 人間のお手本は数分程度の短い単位で記録することが可能で、自動テスト時にはそれらを繋ぎ合わせて再生することでゲーム全体の通しプレイを実現します。
具体的には以下の流れで自動テストを実現します。
- 「記録モード」で人間によるパッド操作、および適切なゲーム内データ (プレイヤー座標、各種フラグなど) を毎フレーム記録し、csv ファイルに保存します。
- 「再生モード」で csv ファイルを記録時の時系列順に 1 行ずつ読み込み、そのデータから記録時の行動に近い操作を生成して実行します。
本稿における「フレーム」は、ゲームから情報が送信される時間の間隔を指します。
COMBridgeManager を利用する場合、1 フレームはデフォルトで 1/30 秒です。
ファイル構成
├─ alfort +-----------------------------+ UE サンプルゲーム(Alfort)向けコード
│ ├─ custom_agents.py +---------------+ 再生時にプレイヤーや UI の操作内容を決定するコード
│ ├─ custom_command.py +--------------+ 単押し以外の特殊操作をまとめて定義するコード
│ ├─ custom_config.py +---------------+ 再生時に必要なパラメータの読み込みコード
│ ├─ custom_core.py +-----------------+ 記録中、および再生中に毎フレーム呼ばれるメインコード
│ ├─ custom_gui.py +------------------+ GUI 実装のうち、ゲーム固有の部分 (セーブデータ関連など) が書かれたコード
│ └─ custom_order.py +----------------+ 再生中以外でタイトル画面やデバッグメニューなどを自動操作するためのコード
│
├─ unity_demo +-------------------------+ Unity サンプルゲーム(unity_demo)向けコード
│ ├─ custom_agents.py +---------------+ 再生時にプレイヤーや UI の操作内容を決定するコード
│ ├─ custom_command.py +--------------+ 単押し以外の特殊操作をまとめて定義するコード
│ ├─ custom_config.py +---------------+ 再生時に必要なパラメータの読み込みコード
│ ├─ custom_core.py +-----------------+ 記録中、および再生中に毎フレーム呼ばれるメインコード
│ ├─ custom_gui.py +------------------+ GUI 実装のうち、ゲーム固有の部分 (セーブデータ関連など) が書かれたコード
│ └─ custom_order.py +----------------+ 再生中以外でタイトル画面やデバッグメニューなどを自動操作するためのコード
│
├─ base +-------------------------------+ ゲーム共通のコード (基底クラス)
│ ├─ base_config.py +-----------------+ 再生時に必要なパラメータの読み込みコード
│ ├─ base_core.py +-------------------+ 記録中、および再生中に毎フレーム呼ばれるメインコード
│ ├─ base_gui.py +--------------------+ GUI 実装のうち、ゲーム共通の部分が書かれたコード
│ └─ base_order.py +------------------+ 再生中以外でタイトル画面やデバッグメニューなどを自動操作するためのコード
│
├─ data
│ ├─ conf +---------------------------+ 再生時に必要なパラメータを設定するためのファイル
│ ├─ font +---------------------------+ GUI 上で用いる日本語フォント
│ └─ icon +---------------------------+ GUI 上で用いるアイコン画像
│
├─ lib
│ ├─ utils +--------------------------+ 便利モジュール
│ │ ├─ aes.util.py +----------------+ メール設定関連のモジュール
│ │ ├─ assert_window_wathcer.py +---+ ポップアップウィンドウを自動で閉じるモジュール (Alfort では不使用)
│ │ ├─ mail_manager.py +------------+ メール設定関連のモジュール (空実装)
│ │ └─ navi_tools.py +--------------+ 距離や角度などの計算用モジュール
│ │
│ └─ wrappers +-----------------------+ ゲーム環境の初期化やゲーム-Python 間のデータ送受信を行うモジュール
│ └─ env_wrapper.py +-------------+ UE/Unity 共通で利用できるゲーム-Python 間通信モジュール
│
└─ playthrough.py +---------------------+ ツール GUI の起動用コード
テストプロセス
GUI 上で選択した全ての手本の再生 (or スキップ) が終わるまで、下図で示すプロセスを繰り返します。 再生回数を 2 回以上に設定した場合、このプロセス全体をその回数だけループして行います。
手本の記録
手本の記録時は、ゲームから毎フレーム送信される様々な情報を時系列に沿って csv に保存します。 再生時は保存した情報を上から順番に読み、手本の内容に合うような自動プレイを行います。
なお、csv に保存すべき情報はゲームによって大きく異なると想定されます。 そのため、各ゲームの攻略に必要だと考えられる情報を適切に選定し、保存/読み込み処理を実装する必要があります。
csv に保存している情報 (サンプルゲームの場合)
列 | 格納している情報 | 型 | 備考 |
---|---|---|---|
0 | プレイヤーの x 座標 | float | |
1 | プレイヤーの y 座標 | float | |
2 | プレイヤーの z 座標 | float | |
3 | プレイヤーの向き (水平方向) | float | |
4 | カメラ角度 (水平方向) | float | |
5 | カメラ角度 (垂直方向) | float | |
6 | 左スティック入力 (x 軸方向) | float | |
7 | 左スティック入力 (y 軸方向) | float | |
8 | Y ボタン入力 | bool | |
9 | X ボタン入力 | bool | |
10 | B ボタン入力 | bool | |
11 | A ボタン入力 | bool | |
12 | 上ボタン入力 | bool | |
13 | 左ボタン入力 | bool | |
14 | 右ボタン入力 | bool | |
15 | 下ボタン入力 | bool | |
16 | R ボタン入力 | bool | |
17 | スタートボタン入力 | bool | |
18 | L トリガーボタン入力 (二値) | bool | |
19 | L トリガーボタン入力 (アナログ値) | float | |
20 | メッセージウィンドウが表示されているか | bool | |
21 | プレイヤーが空中にいるか | bool | サンプルゲーム固有 |
22 | 出せるジャンプが残っているか | bool | サンプルゲーム固有 |
23 | 現在のレベル | str | |
24 | 現在再生されているイベントの ID | str |
ボタン表記は Xbox コントローラー基準になります。
多くのゲームに共通の項目
上記の記録内容のうち、[0]~[20]、[23]、[24] はどのゲームでも共通して記録する必要があると考えられます。 なお、パッド操作部分についてはゲームで割り当てられているボタンに応じて調整する必要があります。
現在の仕様では、ボタン入力は押し始めのタイミングだけ True
として記録するように加工されています。
例えば、10 フレームの間ボタンが長押しされたとしても、最初の 1 フレーム分しか True
として記録されません。
これは、ゲームによっては「押し始めた瞬間から何フレーム経過したかによって処理を変える」ようなことを行いたい場合があり、 あらかじめこのように記録しておいた方が扱いやすいためです。 記録中に行っていた長押し操作を完全再現する必要があるゲームの場合、この実装は変更する必要があります。
特定のゲーム固有の項目
ゲーム固有の記録すべき内容には、例えば以下のようなものが考えられます。
現在銃のエイムモードに入っているか
例えば L ボタンを押した時にエイムモードの開始/終了が行うことができ、 通しプレイの過程で必ずこのモードを使用して特定の敵を倒すイベントが発生するゲームを想定します。 単純には L ボタンのパッド入力を見ればエイムモードに入ったかどうかが判断できそうに思えますが、以下の問題が考えられます。
- L ボタンを押して 非エイムモード → エイムモード に変化したのか、エイムモード → 非エイムモード に変化したのかが区別できない
- 何らかの理由で L ボタンを押したにもかかわらずエイムモードの切り替えに失敗した場合を検知できない
これらを考慮して、確実に特定の敵を倒すイベントを突破するためには、 自動テストエージェントが「記録時と同様に正しくエイムモードに入れたか/解除できたか?」を判定できる仕組みが必要です。 そのためにはエイムモードを表すフラグを記録する必要があります。
必殺技の発動可能条件が満たされているか
- 例えば、記録時には正しく必殺技が発動できていたとしても、再生時には何らかの問題で必殺技が発生できなかったとします。 ここで記録時に必殺技を出すために行われたパッド操作の再現入力が行われると、 エージェントはその段階で必殺技を出し終わったものとし、それを前提として以降に続く行動を行います。 この後に必殺技を出すことがトリガーとなるフラグが存在した場合、テスト進行に失敗してしまいます。
例: サンプルゲーム固有の項目
上記 csv に保存している情報の [21]、[22] は サンプルゲーム固有の情報です。 サンプルゲームには二段ジャンプというアクションがあり、これを適切にハンドリングできるように手本データに加えています。
手本の再生
再生モードでのエージェントの行動は、おおよそ以下の 3 つの処理を毎フレーム繰り返し行うことで動作しています。
- ゲームから送信される情報を受け取る
- その内容と手本データを見てパッド操作内容を決定する
- パッド操作内容を送信し、ゲームに反映する
ここでは 2 つ目の処理、つまりデータを元にどのようなルールでパッド操作内容を決めているかについて説明します。
再生開始前の準備処理
記録時に作成した手本の csv ファイルを全行読み込んだ上で、イベントが再生開始/終了する行数とその EventID を対応付けて dict として格納します。
現在の形式は self.event_startend_index = {EventID: [[event_start_row, event_end_row]]}
で、
同じ ID を持つイベントが同一の手本内で複数回再生されている場合は self.event_startend_index = {EventID: [[event_start_row, event_end_row], [event_start_row2, event_end_row2], ...]}
のようになります。
ゲームによって多少異なるものの、イベントの開始/終了タイミングの判断基準は、 イベント ID が 「None のような値」から「何かしらの意味のある文字列」に変化したタイミング、 もしくはその逆方向に変化したタイミングとすることができると考えられます。
行動決定ロジック
エージェントの行動はステートマシンで管理されており、現在のステートに応じてフレームごとに決定されます。
class CustomAgent
のクラス変数 self.step_state
が現在の state を表します。
通しプレイにおいて一番よく使われるであろう基本の state は move_checkpoint
です。
これは記録した手本で通っている道に沿って移動するためのもので、初期化時もこの state から始めるようにしています。
移動以外の行動については、move_checkpoint
の行動途中で特定条件を満たした場合に別の state へ遷移することで行われます。
また、その state の処理が完了したら move_checkpoint
に戻るようになっています。
ただ、意味的にまとまった行動であれば、move_checkpoint
に戻らずとも他の state を複数跨ぐこともあり得ます
(最終的に move_checkpoint
に戻ることは変わりありません)。
毎フレームのゲーム内情報と手本データを元にゲームに送信するパッド操作内容を決定している関数
(custom_agents.py > class CustomAgent > step
) の処理概要をフローチャートに示します。
ステートマシンの概要
下記の図は、一般的なアクション RPG ゲームにおいて通しプレイを実現しようとした場合に、最低限必要だと考えられるステートマシンの状態遷移図です。 このステートマシンをベースに各ゲーム固有の state を追加する形になると考えられます。 以降で各 state の説明を行います。コードのコメントや 新規ゲームへの対応手順 にも説明がありますので参照してください。
move_checkpoint の処理
手本のルートに従った移動のみを行う state です。 厳密には、手本記録時における 5 フレームおきの座標を経由点に設定し、経由点間は直線移動を行います。 なので、手本のルート上を正確に移動するわけではありません。
なお、何フレーム間隔で経由点を設定するかは data/conf
内の yaml ファイルで変更できます。
また、定期的に現在参照中の手本の行数 (現在の座標に相当) をクラス変数 self.event_buffer
(list[int or str]) に追加しています。
これは移動失敗時に少し前の座標に遡って移動をやり直すために用いられるもので、ここで追加した座標が復帰地点に用いられます。
preprocess_npctalk の処理
これは state ではなく関数の名前ですが、 NPC に話しかけたり、看板などのオブジェクトを調べたりするなど、会話イベントに突入するための前処理を行う state のようなものです。 「プレーヤーの座標と向きを手本記録時と同じになるよう揃えてから A ボタンを連打する」という操作を行っています。
処理の概要は下の図を参照してください。
message_skip の処理
イベント中のメッセージやムービーなどをスキップするための state です。 一般に、これらは特定のボタンを連打することでスキップできることが多いので、例外的な操作が必要ない場合はひたすらボタンを連打するだけの state となります。
イベント中にメッセージを送る以外の例外操作が必要な場合、それに対処するためのルールを関数 process_event
に記述する必要があります。
また、スキップし終わりイベントが終了した後に move_checkpoint
に遷移しますが、その前に様々な処理を行う必要があります。
処理の概要は下の図を参照してください。
pad_replay 時の処理
記録時のパッド操作を忠実に再現するための state です。
move_checkpoint
との違いは、手本データを数行おきではなく毎行参照して操作に反映する点と、移動だけではなくボタン操作も再現する点です。
この state は操作内容をルールで記述するには困難だったり、記録時に限りなく近い操作をしないとプレイヤーを進めることができなかったりする場合に用いられます。
例えば以下の状況が考えられます。
- ジャンプしながら移動を行う場合
- 記録時にジャンプして別の足場に飛び移った時、その別の足場にギリギリ届いた場合を考えます。
move_checkpoint
は記録時のルートから数フレームおきに経由点を抽出し、経由点の間は直線移動を行うものですので、 ギリギリ届いた足場の上に経由点が選出されなかった場合、移動に失敗します。 また、2 段ジャンプがあるゲームなどを考慮すると、一概に移動ルールを決めることは困難だと考えられます。 よって、一時的に記録時の操作をそのまま使うことで、移動の成功率を高めることができます。
- 記録時にジャンプして別の足場に飛び移った時、その別の足場にギリギリ届いた場合を考えます。
- ショップでアイテムを購入する場合
- ゲーム進行上、特定のフラグを立たせるためにアイテムを購入する必要がある場合を考えます。 このとき必要なアイテムの購入までの操作が常に一定であれば、記録時の操作をそのまま使うことで簡単にアイテム購入を実現できます。
この state の開始条件は、参照中の手本の行データが pad_replay
を開始したいタイミングになっていることです。
先の例で言えば「ジャンプボタンが押されたタイミング」や「ショップに入ったタイミング」に該当します。
これはコード上の if
文で指定する必要があります。指定のためには以下の 2 箇所に変更を加える必要があります。
- 関数
next_move_checkpoint_data
の経由点を抽出する部分で、pad_replay
を開始したい瞬間の座標を経由点に強制設定する - 関数
check_state_transition
に参照中の手本の行データやゲームから送信されるデータがどのような内容になった時にpad_replay
に遷移するかを記述する
この state の終了条件は data/conf
内の yaml ファイルで設定しただけの時間が経過した時です。
この制限時間は 10 ~ 20 フレーム程度のあまり長くない時間に設定することが望ましいです。
state 移行の直前で変数 self.pad_replay_numb
に時間が代入され、pad_replay
の間は同変数が毎フレームデクリメントされます。
これが 0
になったら制限時間を過ぎたと判定されます。
ゲームと Python プログラム間で TCP 通信を行うため、通信処理、入力処理、ゲームの処理が完全に同じタイミングで動くわけではないことに注意してください。
float 値の入力を持つスティック操作に誤差が生まれ、これが長時間に及ぶと誤差は大きくなるため、
pad_replay
の制限時間を長く取ると記録時のルートから徐々にズレが発生します。
なお、上記例のショップでアイテムを購入する場合のように、パッド操作のみで完結しスティック操作が一切発生しないパターンでは、
時間制限を設けずにそのモード (上記例ではショップ画面) が終了するまでは常に pad_replay
状態を保つのが適切です。
この場合は時間制限なくパッドリプレイを行うような新しい state を用意するとよいでしょう。
collect_item の処理
プレイヤーの一定範囲内に未回収のアイテムが存在するとこの state に遷移し、そのアイテムを手本の記録内容にかかわらず 完全ルールベース で回収します。 この仕組みから、手本の記録時には回収しなかったアイテムも回収する可能性があります (テストに影響はありません)。
逆に、記録時と同じ経路を通れば、記録時に回収したアイテムの近くも必ず通るので、 「ゲーム進行上回収が必須のアイテムを記録時には回収したのに、再生時には回収できなかった」ということは起こりません。
対象のアイテムを回収したら back_to_demoroute
(後述) に遷移します。
様々な要因でアイテムの回収に失敗することがあるため、対策として collect_item
に遷移してから
アイテムが回収できないまま一定時間が経過しても back_to_demoroute
に遷移するようになっています。
この場合は対象のアイテムが引き続き存在しているため、再度 collect_item
に遷移してアイテムの回収を試みます。
回収操作に問題が発生しても、基本的にこれを何度か繰り返せばいずれ回収に成功し、正常にテストを進められることがわかっています。
enemy_battle の処理
プレイヤーの一定範囲内にランダムにポップする敵が存在するとこの state に遷移し、その敵を手本の記録内容にかかわらず 完全ルールベース で倒します。
敵は一定範囲内をランダムに動く仕様になっていることが多いため、記録時に戦闘を行った座標で攻撃の再現を行っても倒せない可能性が高いです。 よって、記録内容にかかわらずルールベースで行動を決める必要があります。
これはボス戦などのゲーム進行上必ず倒す必要がある敵との戦闘も同様です。
ボス戦に関する詳しい説明は boss_battle
の項を参照してください。
まず state 遷移直後の処理として、プレイヤーの一定範囲内、かつ複数いる場合は最も近い敵をターゲットに設定します。
ここでターゲットに設定した敵を識別するための情報をクラス変数 self.tar_enemy
に格納します。
その後、毎フレーム送信されるゲーム内情報を参照し、self.tar_enemy
に格納したものと同じ情報を持つ敵の座標に向かい、
敵に十分近付いたら HP が 0 になるまで攻撃をし続けます。
与ダメージによる吹っ飛びなどで敵がプレイヤーから離れたとしても追跡できるようになっています。
対象の敵を倒したら back_to_demoroute
(後述) に遷移します。
サンプルゲームでは同じ種別の敵 (同じ見た目の敵) には同じ ID が割り振られているため、 ターゲットに設定した敵と同じ種別の敵が近くに複数いる時は、単独の敵を集中的に狙えないことがあります。
boss_battle の処理
ゲーム進行上必ず倒す必要がある敵 (ボスなど) と戦うための state です。 この state に遷移する条件は、以下の例のようにゲームやボス戦の仕様によって多岐に渡ります。
- ボス戦用の特殊フィールドが用意されている場合、 ゲーム改造でプレイヤーがいるフィールドを識別できるようにした上で、プレイヤーが専用フィールドに存在することを遷移条件とすることができます。
- 専用フィールドが用意されてはいないが、ボスの出現場所が決まっている場合、 プレイヤーがボス戦が行われる一定の領域内に存在する、かつボスが存在することを遷移条件とすることができます。 この時、近くにいる敵がボスであるか、場合によってはどのボスであるかを判別できる情報をゲーム改造で取得できるようにする必要があります。
戦闘中の処理は enemy_battle
と同じですが、ゲームによって攻撃以外の特殊操作が必要な場合は行動ルールを適宜カスタマイズする必要があります。
state の終了条件は対象となるボスや、取り巻きがいる場合はそれらも全員倒した後となります。state 終了後は move_checkpoint
に遷移します。
back_to_demoroute の処理
手本に従わず、完全ルールベースの行動を取る state (毎フレーム行われる行動決定ロジック の図も参照) が完了した後に遷移する state です。
これは上記 state に遷移する際に現在の座標、つまり手本通りの移動を行っていた時に最後にいた座標を記録しておき
(関数 step_normal_move
でクラス変数 last_checkpoint
に記録されます)、
その state の終了後に記録しておいた座標に戻るためのものです。
記録しておいた座標には現在地から直線移動で戻り、到着後は move_checkpoint
に遷移します。
state の遷移について
state の遷移は関数 check_state_transition
で行います。ここに move_checkpoint
から他の state に遷移する際の条件が記述されています。
なお、state を move_checkpoint
に戻す条件については、各 state の処理中でその state を抜ける際に元に戻すよう書かれています。
注意点として、if
文の性質上、同時に複数の条件を満たすものは上に書かれているものが優先されるため、
同時に複数条件を満たした場合にどの state へ優先的に遷移するかを考えて記述する必要があります。
例えば、敵の攻撃による吹っ飛ばしでプレイヤーの移動経路がズレると、移動に失敗する可能性が出てきます。 よって、現在のコードでは戦闘関連 state の優先度は高く、移動関連 state の優先度は低くしてあります。 つまり、手本再生の障害になりうる近くの敵は全て倒してから移動を行うようになっています。
また、state 遷移時には参照中の手本の行数をクラス変数 self.event_buffer
(list[int or str]) に追加しています。
これは移動失敗時に少し前の座標に遡って移動をやり直すために用いられるものです。
state 遷移後は重要な操作、つまり失敗するとテスト進行に大きな悪影響が出る操作が行われる可能性が高いことを考え、
state 遷移前をやり直しの復帰地点に設定しています。
再生に詰まった場合の自動処理
ツールマニュアル - 補足事項にも書かれているように、様々な要因で手本通りの移動に失敗することがあります。 その状況を自動で上手く脱し、手本通りの移動を再開できるようにするための処理が組み込まれています。
この処理の概要は以下の通りです。
このうち黄緑色の枠で囲った部分については core/custom_agents.py
の step_normal_move
、move_recovery
関数が参考になります。
ゲームエンジン毎の差異の吸収
Playthrough Tester ではゲームエンジン毎の座標系、距離単位、ゲームから受け取るデータなどを差異を以下のように対処しております。
data/conf
フォルダ以下の yaml ファイルにゲームが採用しているゲームエンジンの種別を記述します。- ゲームエンジン毎の差異を吸収する処理は
lib/utils/navi_tools.py
に記述されています。 (詳細は当該コードを参照してください) - ゲーム固有の処理を実装している
custom_*.py
では上記に実装した関数を呼び出すようになっています。
注意事項
- 記録中に意図的に立ち止まる時間があった場合、現状は再生時には立ち止まらない仕様になっています (手本データで xy 座標の変化が一定以下であるフレームは読み飛ばすようにしています)。 このようなフレームも読み込むと、通常移動時の目標座標が高密度で設定されるので移動がカクついたり、ジャンプが失敗する可能性が高まるためです。 一方で、ゲームによっては真下に落下したり真上に上昇したりといった移動も考えられるため、 このような場合は xy 座標を一定時間動かさない場合を考慮した操作を行うように修正する必要があります。
- 記録時の操作を (ほぼ) トレースするツールであるため、ランダム要素への対応が非常に困難です。 強制的にゲームを進行できるようなデバッグ機能をゲーム側で用意するなどの対応が必要になります。