(poke-envの存在を知らずに作ってしまった...
手番以降の盤面の部分探索ができる、実機対戦に対応している点で差別化できていると信じたい)
【ソースコード 】対戦シミュレータ + 実機対戦Bot
github.com
【ソースコード 】ランクマッチの使用率を取得
github.com
目次
できること
対戦シミュレーション
致死率計算 (いわゆるダメージ計算)
ポケモン SVの実機制御
概要
「強いAIを作ったので公開します!」という趣旨ではなく、あくまでコンピュータに実機で対戦させる仕組みを作ってみたというものです。
今回の内容は2つの記事に分けて紹介します。
対戦シミュレータの導入 (本記事)
実機対戦Bot の導入 (後編 )
はじめに、コンピュータが先読みできるように、ポケモン SVの対戦システムを再現するシミュレータを導入します。
続いて、このシミュレータに画面解析やコマンド入力などの機能を追加して、実機ポケモン SV上で自立して対戦するBot を作ります。
必要なもの
シミュレータを使うには、
さらにBot を作るには、
PCからSwitchを操作するためにnxbtというライブラリを使用しますが、これはLinux (仮想)環境でしか動作しないようです。
形成判断AIのようにSwitchを操作しないアプリを作るのであれば、どのOSでもよいと思います。
シミュレータの導入
筆者の環境
基本的な使い方
2匹のポケモン を戦わせてみます。
"""ex_random_1on1.py"""
from pokepy.pokemon import *
Pokemon.init()
battle = Battle()
battle.selected[0 ].append(Pokemon('カイリュー' ))
battle.selected[1 ].append(Pokemon('ガチグマ(アカツキ)' ))
while battle.winner() is None :
battle.proceed()
print (f' \n ターン{battle.turn}' )
for player in battle.action_order:
print (f'{player=}' , battle.log[player], battle.damage_log[player])
出力結果
ターン0
player=0 ['交代 -> カイリュー'] []
player=1 ['交代 -> ガチグマ(アカツキ)'] []
ターン1
player=0 ['カイリュー', 'HP 166/166', 'コマンド 11', '先手', 'テラスタル ノーマル', 'しんそく PP 7', 'ダメージ 58', '相手HP 130', 'しんそく 成功', 'HP -54', 'D-1'] ['ノーマルテラスタル x1.5']
player=1 ['ガチグマ(アカツキ)', 'HP 188/188', 'コマンド 1', '後手', 'HP -58', 'だいちのちから PP 15', '急所', 'ダメージ 54', '追加効果', '相手HP 112', 'だいちのちから 成功'] ['マルチスケイル x0.50', '急所 x1.5']
ターン2
player=0 ['カイリュー', 'HP 112/166', 'コマンド 1', '先手', 'しんそく PP 6', 'ダメージ 57', '相手HP 73', 'しんそく 成功', 'HP -99'] ['ノーマルテラスタル x1.5']
player=1 ['ガチグマ(アカツキ)', 'HP 130/188', 'コマンド 3', '後手', 'HP -57', 'ハイパーボイス PP 15', 'ダメージ 99', '相手HP 13', 'ハイパーボイス 成功'] []
ターン3
player=1 ['ガチグマ(アカツキ)', 'HP 73/188', 'コマンド 12', '先手', 'テラスタル ノーマル', 'しんくうは PP 47', '急所', 'ダメージ 13', '相手HP 0', '勝ち'] ['急所 x1.5']
player=0 ['カイリュー', 'HP 13/166', 'コマンド 2', '後手', 'HP -13', '負け'] []
0ターン目にカイリュー とガチグマが場に出て、殴り合った結果ガチグマが勝ちました。
ポケモン を3匹ずつ選出すれば3対3の試合になります。
"""ex2_random_3on3.py"""
(略)
for player in range (2 ):
names = random.sample(list (Pokemon.home.keys()), 3 )
print (f'{player=} の選出 {names}' )
for i in range (3 ):
battle.selected[player].append(Pokemon(names[i]))
while battle.winner() is None :
(略)
出力結果
player=0 の選出 ['ルカリオ', 'カジリガメ', 'オーロンゲ']
player=1 の選出 ['エーフィ', 'ルチャブル', 'リザード']
ターン0
player=0 ['交代 -> ルカリオ'] []
player=1 ['交代 -> エーフィ'] []
ターン1
player=1 ['エーフィ', 'HP 140/140', 'コマンド 22', '先手', '交代 -> リザード', '行動不能 交代', 'HP -55'] []
player=0 ['ルカリオ', 'HP 145/145', 'コマンド 0', '後手', 'しんそく PP 7', 'ダメージ 55', '相手HP 78', 'しんそく 成功'] []
(略)
1on1では技を使うだけでしたが、2匹以上選出すると交代もできるようになります。
上の例では、1ターン目にエーフィがリザード に交代しています。
対戦シミュレーションの基本的な流れは次のようになります。
Battleインスタンス を生成する
Pokemonインスタンス を生成して選出に追加する
proceed()メソッドでターンを進める
Battleクラスは試合や盤面などの対戦システム全般を、Pokemonクラスはポケモン の個体や関連情報を表現します。
ポケモン を生成して中身を確認してみます。
"""ex3_generate_pokemon.py"""
from pokepy.pokemon import *
Pokemon.init(season=None )
name = 'ガチグマ(アカツキ)'
p = Pokemon(name, use_template=True )
print ('-' *50 + ' \n インスタンス変数 \n ' + '-' *50 )
print (f'名前 \t {p.name}' )
print (f'タイプ \t {p.types}' )
print (f'体重 \t {p.weight}' )
print (f'性別 \t {p.sex}' )
print (f'レベル \t {p.level}' )
print (f'性格 \t {p.nature}' )
print (f'元の/現在の特性 \t {p.org_ability} / {p.ability}' )
print (f'アイテム \t {p.item}' )
print (f'テラスタイプ \t {p.Ttype}' )
print (f'種族値 \t {p.base}' )
print (f'個体値 \t {p.indiv}' )
print (f'努力値 \t {p.effort}' )
print (f'ステータス \t {p.status}' )
print (f'HP \t {p.hp} ({p.hp_ratio*100}%)' )
print (f'わざ \t {p.moves}' )
print (f'PP \t {p.pp}' )
print (f'能力ランク \t {Pokemon.status_label} = {p.rank}' )
print ('-' *50 + ' \n ランクマッチの使用率 \n ' + '-' *50 )
print (Pokemon.home[name]['nature' ])
print (Pokemon.home[name]['ability' ])
print (Pokemon.home[name]['item' ])
print (Pokemon.home[name]['Ttype' ])
print (Pokemon.home[name]['move' ])
出力結果
--------------------------------------------------
インスタンス変数
--------------------------------------------------
名前 ガチグマ(アカツキ)
タイプ ['じめん', 'ノーマル']
体重 333.0
性別 0
レベル 50
性格 ひかえめ
元の/現在の特性 しんがん / しんがん
アイテム
テラスタイプ ノーマル
種族値 [113, 70, 120, 135, 65, 52]
個体値 [31, 31, 31, 31, 31, 31]
努力値 [0, 0, 0, 0, 0, 0]
ステータス [188, 90, 140, 155, 85, 72]
HP 188 (100%)
わざ ['ブラッドムーン', 'だいちのちから', 'しんくうは', 'ハイパーボイス']
PP [8, 16, 48, 16]
能力ランク ('H', 'A', 'B', 'C', 'D', 'S', '命中', '回避') = [0, 0, 0, 0, 0, 0, 0, 0]
--------------------------------------------------
ランクマッチの使用率
--------------------------------------------------
[['ひかえめ', 'ずぶとい', 'れいせい', 'おくびょう', 'おだやか', 'がんばりや', 'のんき', 'なまいき', 'わんぱく', 'いじっぱり'], [73.6, 10.8, 6.5, 3.9, 2.4, 2.1, 0.4, 0.1, 0.0, 0.0]]
[['しんがん'], [100.0]]
[['とつげきチョッキ', 'たべのこし', 'シルクのスカーフ', 'オボンのみ', 'きあいのタスキ', 'いのちのたま', 'ゴツゴツメット', 'アッキのみ', 'おんみつマント', 'のどスプレー'], [42.5, 15.6, 13.8, 10.5, 6.3, 2.1, 1.9, 1.8, 1.3, 0.7]]
[['ノーマル', 'どく', 'フェアリー', 'みず', 'ほのお', 'ゴースト', 'でんき', 'かくとう', 'くさ', 'ひこう'], [65.6, 15.8, 12.7, 2.1, 0.9, 0.9, 0.7, 0.6, 0.2, 0.1]]
[['ブラッドムーン', 'だいちのちから', 'しんくうは', 'ハイパーボイス', 'あくび', 'つきのひかり', 'めいそう', 'ムーンフォース', 'ちょうはつ', 'テラバースト'], [96.8, 92.2, 73.8, 55.3, 26.0, 25.6, 11.7, 9.7, 2.6, 2.4]]
名前のみを指定してポケモン を生成したにもかかわらず、それらしい技構成になっています。
これはコンストラク タの引数で use_template=True を指定すると、内部で apply_template() メソッドが呼ばれ、ランクマッチ使用率上位の特性や技がセットされていたためです。
デフォルトの型をカスタマイズしたい場合はオーバーライドするとよいでしょう。
"""ex3_view_pokemon.py"""
class MyPokemon (Pokemon):
def __init__ (self, name: str = 'ピカチュウ' , use_template: bool = True ):
super ().__init__(name, use_template)
def apply_template (self):
"""デフォルトの型を設定する"""
if self.name in Pokemon.home:
self.nature = Pokemon.home[self.name]['nature' ][0 ][0 ]
self.org_ability = Pokemon.home[self.name]['ability' ][0 ][0 ]
self.Ttype = Pokemon.home[self.name]['Ttype' ][0 ][0 ]
self.moves = Pokemon.home[self.name]['move' ][0 ][:4 ]
MyPokemon.init(season=None )
(略)
行動と方策関数
ポケモン の行動は整数のコマンドに基づいて制御されます。
コマンド
行動
入力するタイミング
0~9
n番目の技を選択
ターン開始時
10~19
テラスタル して(n-10)番目の技を選択
ターン開始時
20~25
(n-20)番目に選出したポケモン に交代
ターン開始・任意交代時*
30 または Battle.STRUGGLE
わるあがき
ターン開始時
40 または Battle.NO_COMMAND
命令できない
ターン開始時
-1 または Battle.SKIP
行動スキップ
ターン開始時 (通常発生しない)
任意交代とは、とんぼがえりや死に出しにより発生する、ターン途中の交代のことを指します。
シミュレータ上では技を4つに制限する必要がないため、10通りの技選択コマンドを用意しました。
proceed() メソッドによるターン経過の途中に、方策関数によって入力されるコマンドが決定されます。
方策関数には battle_command() と change_command() の2種類があり、それぞれターン開始時と任意交代時に呼ばれます。
"""pokepy/pokemon.py"""
class Battle :
(中略)
def battle_command (self, player: int ) -> int :
"""{player}のターン開始時に呼ばれる方策関数"""
return random.choice(self.available_commands(player))
def change_command (self, player: int ) -> int :
"""{player}の任意交代時に呼ばれる方策関数"""
return random.choice(self.available_commands(player, phase='change' ))
デフォルトでは、現在選択可能なコマンドの一覧を available_commands() メソッドで取得し、その中からランダムに選んでいます。
そのため、最初の1on1や3on3の例では、ポケモン はランダムに行動していました。
battle_command()メソッドが呼ばれるタイミングのイメージ
change_command()メソッドが呼ばれるタイミングのイメージ
先読み
シンプルな先読みの例として「選択可能なコマンドをすべて実行してみて、一番マシな盤面になるコマンドを選ぶ」ような方策関数を実装してみます。
"""ex4_bruteforce_1on1.py"""
from pokepy.pokemon import *
class MyBattle (Battle):
def __init__ (self):
super ().__init__()
def battle_command (self, player):
"""{player}のターン開始時に呼ばれる方策関数"""
blinded = self.clone(player)
available_commands_list = [
blinded.available_commands(pl) for pl in [player, not player]
]
scores = []
for c0 in available_commands_list[0 ]:
_scores = []
for c1 in available_commands_list[1 ]:
battle = deepcopy(blinded)
battle.proceed(commands=([c0, c1] if player == 0 else [c1, c0]))
if battle.was_valid[player]:
_scores.append(battle.score(player))
else :
_scores.append(0 )
scores.append(min (_scores))
return available_commands_list[0 ][scores.index(max (scores))]
def score (self, player: int ) -> float :
"""盤面の評価値を返す"""
return (self.TOD_score(player) + 1e-3 ) / (self.TOD_score(not player) + 1e-3 )
Pokemon.init()
battle = MyBattle()
(略)
方策関数が呼ばれている間、コマンド入力待ちのためターン処理は中断されています。
clone() メソッドで現在の盤面を複製し、得られた仮想盤面 にコマンドを渡してターンを再開します。
その後、仮想盤面の終状態の評価値に基づいてコマンドを評価しています。
盤面の複製については次節で詳しく見ていきます。
score() メソッドで利用している TOD _score とは、内部的に試合の勝敗判定に用いられる評価値で、次のように定義されます。
ポケモン が全滅するとTOD _scoreは0になり、判定勝負ではTOD スコアの大きいプレイヤーが勝利します。
交代の方策関数 change_command() でも同様の探索を行うことができます。
"""ex5_bruteforce_3on3.py"""
from pokepy.pokemon import *
class MyBattle (Battle):
def __init__ (self):
super ().__init__()
def battle_command (self, player: int ) -> int :
"""{player}のターン開始時に呼ばれる方策関数"""
return self.available_commands(player)[0 ]
def change_command (self, player: int ) -> int :
"""{player}の任意交代時に呼ばれる方策関数"""
available_commands = self.available_commands(player, phase='change' )
print (' \t ' +'-' *30 + ' 交代の方策関数 ' + '-' *30 )
print (' \t ここまでの展開' )
for pl in self.action_order:
print (f' \t\t Player {pl} {self.log[pl]}' )
scores = []
for cmd in available_commands:
battle = self.clone(player)
battle.proceed(change_commands=[cmd, None ] if player == 0 else [None , cmd])
print (f' \t コマンド{cmd}を指定して仮想盤面を再開' )
print (f' \t\t Player {player} {battle.log[pl]}' )
scores.append(battle.score(player))
print (' \t ' +'-' *76 )
return available_commands[scores.index(max (scores))]
def score (self, player: int ) -> float :
"""盤面の評価値を返す"""
return (self.TOD_score(player) + 1e-3 ) / (self.TOD_score(not player) + 1e-3 )
Pokemon.init()
battle = MyBattle()
battle.selected[0 ].append(Pokemon('ママンボウ' ))
battle.selected[0 ][-1 ].moves = ['クイックターン' ]
battle.selected[0 ].append(Pokemon('オーロンゲ' ))
battle.selected[0 ].append(Pokemon('グライオン' ))
battle.selected[1 ].append(Pokemon('カイリュー' ))
battle.selected[1 ].append(Pokemon('ガチグマ(アカツキ)' ))
battle.selected[1 ].append(Pokemon('サーフゴー' ))
while battle.winner() is None :
battle.proceed()
(略)
出力結果
ターン0
player=0 ['交代 -> ママンボウ'] []
player=1 ['交代 -> カイリュー'] []
------------------------------ 交代の方策関数 ------------------------------
ここまでの展開
Player 1 ['カイリュー', 'HP 166/166', 'コマンド 0', '先手', 'じしん PP 15', 'ダメージ 69', '相手HP 171', 'じしん 成功', 'HP -7']
Player 0 ['ママンボウ', 'HP 240/240', 'コマンド 0', '後手', 'HP -69', 'クイックターン PP 31', 'ダメージ 7', '相手HP 159', 'クイックターン 成功']
コマンド21を指定して仮想盤面を再開
Player 0 ['ママンボウ', 'HP 240/240', 'コマンド 0', '後手', 'HP -69', 'クイックターン PP 31', 'ダメージ 7', '相手HP 159', 'クイックターン 成功', '交代 -> オーロンゲ']
コマンド22を指定して仮想盤面を再開
Player 0 ['ママンボウ', 'HP 240/240', 'コマンド 0', '後手', 'HP -69', 'クイックターン PP 31', 'ダメージ 7', '相手HP 159', 'クイックターン 成功', '交代 -> グライオン']
----------------------------------------------------------------------------
ターン1
player=1 ['カイリュー', 'HP 166/166', 'コマンド 0', '先手', 'じしん PP 15', 'ダメージ 69', '相手HP 171', 'じしん 成功', 'HP -7'] []
player=0 ['ママンボウ', 'HP 240/240', 'コマンド 0', '後手', 'HP -69', 'クイックターン PP 31', 'ダメージ 7', '相手HP 159', 'クイックターン 成功', '交代 -> オーロンゲ'] ['マルチスケイル x0.50']
ママンボウ がクイックターン を使用した後、方策関数内で仮想的にオーロンゲとグライオン の両方への交代を試しています。
上の例で見たように、任意交代による中断状態でコマンドを指定して proceed() メソッドを実行すると、交代直前から処理が再開され、ターンの終わり (or 次の交代) まで進みます。この状態はもとの盤面を複製しても持続するため、上記のような方策関数を実装できます。
不完全情報の表現
clone() メソッドはその盤面のdeepcopyを返しますが、引数のplayerを指定すると、そのプレイヤーの視点に相当するように相手の情報を隠蔽します。
相手ポケモン の隠蔽
selectedに格納されたポケモン の情報はそのプレイヤーしか知り得ない、いわば真値です。
proceed() メソッドでターンを進めていくと、場に出たポケモン や試合中に発動した技・アイテムなど、実際にゲーム画面から観測されるような情報はobservedに蓄積されます。
clone() メソッドによるポケモン の隠蔽では、相手のselectedの参照先をobservedのコピーに置き換えます。
このとき、相手の技が一つも観測されていないと後々エラーになる可能性があります。対策として、置き換え後に complement_pokemon() メソッドにより相手ポケモン の情報を補完します。
"""pokepy/pokemon.py"""
class Battle :
(中略)
def complement_pokemon (self, pokemon):
"""ポケモンの情報を補完する"""
if not pokemon.moves:
if pokemon.name in Pokemon.home:
pokemon.add_move(Pokemon.home[pokemon.name]['move' ][0 ][0 ])
else :
pokemon.add_move('テラバースト' )
相手の行動の隠蔽
ターン処理の途中で clone() メソッドを実行すると、相手の行動が隠蔽される場合があります。
先手で任意交代するとき、後手の相手の技選択を complement_move() メソッドで書き換える
相手の場のポケモン が瀕死になったとき、相手の交代コマンドを complement_change_command() メソッドで補完する
"""pokepy/pokemon.py"""
class Battle :
(中略)
def complement_move (self, player: int ) -> str :
"""相手の行動が開示されていない場合に呼ばれ、{player}が選択した技を返す"""
available_moves = []
for cmd in self.available_commands(player):
if cmd >= 10 :
break
available_moves.append(self.pokemon[player].moves[cmd])
return random.choice(available_moves)
def complement_change_command (self, player: int ) -> int :
"""相手の行動が開示されていない場合に呼ばれ、{player}が選択した交代コマンドを返す"""
return 20 + random.choice(self.changeable_indexes(player))
ログの読み書きと乱数
シミュレーションのログを出力するには、試合終了後に dump() メソッドを使用します。
"""ex2_random_3on3.py"""
(略)
while battle.winner() is None :
battle.proceed()
with open ('log/random_3on3.json' , 'w' , encoding='utf-8' ) as fout:
fout.write(battle.dump())
log/random_3on3.json
{
"seed": 1727528188,
"0": [
{Player0が選出したポケモンの情報 (割愛)},...,{}
],
"1": [
{Player1が選出したポケモンの情報 (割愛)},...,{}
],
"Turn-1": {
"command": [null, null],
"change_command_history": [
[],
[]
]
},
"Turn0": {
...
}
次の3つの情報があれば試合を復元することができます。
選出したポケモン
乱数のシード
ターンごとの入力コマンド
出力したjson ファイルを読み込んで試合を再現してみます。
"""ex6_replay_sim.py"""
from pokepy.pokemon import *
Pokemon.init()
with open ('log/random_3on3.json' , encoding='utf-8' ) as fin:
log = json.load(fin)
battle = Battle(seed=log['seed' ])
for player in range (2 ):
for p in log[str (player)]:
battle.selected[player].append(Pokemon())
battle.selected[player][-1 ].__dict__ |= p
battle.selected[player][-1 ].show()
while (key := f'Turn{battle.turn}' ) in dict :
battle.reserved_change_commands = log[key]['change_command_history' ]
battle.proceed(commands=log[key]['command' ])
for player in battle.action_order:
print (f'{player=}' , battle.log[player], battle.damage_log[player])
シードが与えられたときにターン処理が一意に定まるよう、Battleクラスは乱数生成器(Battle._random)をメンバ変数に持ちます。ターン処理中に発生するダメージや命中率、追加効果などの確率事象の計算には必ずこの乱数生成器を使用します。一方で、方策関数内での探索といった盤面に干渉しない計算では、メンバ変数以外の乱数生成器 (普通の random とか) を使用してください。
致死率計算
一般的にダメージ計算と呼ばれる機能です。
対戦シミュレーションでは様々な乱数が作用しますが、致死率計算では乱数を排除し、すべてのダメージの組み合わせを網羅的に計算します。
"""ex7_lethal.py"""
from pokepy.pokemon import *
Pokemon.init()
p1 = Pokemon('カイリュー' , use_template=False )
p1.show()
p2 = Pokemon('ガチグマ(アカツキ)' , use_template=False )
p2.show()
player = 0
move_list = ['スケイルショット' ]
print (move_list)
n_hit = 5
battle = Battle()
battle.pokemon = [p1, p2]
print (battle.lethal(move_list=move_list, player=player, n_hit=n_hit))
print (battle.damage_log[player])
出力結果
['スケイルショット']
80~105 (42.6~55.9%) 乱2(20.84%)
[]
致死率計算では防御側のHPを {'hp': 場合の数} のように辞書型で管理します。ダメージが累積したときの場合の数は個々の場合の数の積で求まるため、すべての分岐をリストで扱うよりも計算コストを大幅に削減できます。
さらに、
{"100" : 3} → アイテムを保持しているHP100の分岐が3通りある
{"100.0" : 3} → アイテムを保持していないHP100の分岐が3通りある
のようにキーを加工してアイテムの有無を追跡することで、きのみの発動条件などを考慮して致死率を計算します。
カスタマイズ
以下のメソッドはオーバーライドして使うことを想定しています。
class Battle :
def battle_command (self, player: int ) -> int :
def change_command (self, player: int ) -> int :
def choose_damage (self, player: int , damage_list: list [int ]) -> int :
def hit_probability (self, player: int , move: str ) -> int :
def critical_probability (self, player: int , move: str ) -> float :
def complement_pokemon (self, pokemon) -> None :
def complement_move (self, player: int ) -> str :
def complement_change_command (self, player: int ) -> int :
def estimate_attack (self, player: int , name: str , status_index: int , recursive: bool =True ) -> bool :
def estimate_defence (self, player: int , name: str , status_index: int , recursive: bool =True ) -> bool :
最後の相手の攻守を推定するメソッドは、方策関数の冒頭などで使うとよいでしょう。
後編へ
hfps4469.hatenablog.com