2025年にやりたい100のこと
「ふつうの軽音部」クワハリ先生のnoteが面白かったので真似してみる
もうすぐ30歳、モチベ上げていけ~
達成率 【10/100】
1. ポケモン対戦AIにランクマ最終4桁をとらせる
2. スマホを持たずに1日外出する
3. FPGAで何か作る
4. 木材 or アクリルでDIYする
5. 鳥カフェに再訪する
6. ランニングする
7. 魚を釣る
8. 音楽ライブ・コンサートに行く
9. 裁判を傍聴する
10. スポーツ観戦する
11. 見たことない野鳥を見る
九十九里浜のミユビシギ、歩き方かわいい

12. 野草を採る
13. カヌレを焼く
14. イタリア土産のレシピ本から一品作る
15. 魚を燻製する
キハダマグロ冷燻、噛むとじわぁと旨みを感じて美味しかった

16. メルカリ出品数50を達成する
引っ越し時に大量の文庫本を売った

17. ハードカバーの本を買う
ゲームさんぽで名越先生が紹介していたので読んでみた

18. ピアノでカノン(初級者レベル)を弾く
19. 深夜ラジオをリアタイする
20. 遊戯王MDで烙印デッキを組む
必須UR多すぎるんよ...

21. レドデ2をクリアする
22. 賭博する
23. フェリーに乗る
24. 孤独のグルメに出た店に行く
25. 聖地巡礼する
26. 会ったことない人に会う
27. レンタルコミックで最新話まで読む
ヒロアカ完走!
28. バーに行く
29. 動画を作る
30. 日の出を見る
31. イタリア語で自己紹介する
32. お笑いライブに行く
33. 山に登る
34. 24年度の有給休暇を使い切る
繰り越し休暇は使い切ったのでヨシ!
35. フランス料理のソースを作る
36. 芋からおいしいフライドポテトを作る
37. 食べたことのない料理を食べる
38. 馬に乗る
39. 海水から塩を作る
40. モスのレギュラーバーガーを制覇する
41. 株を買う
42. 虹を見る
43. 5,000円のワインを飲む
44. 失くしたもう片方の靴下を見つける
引っ越したら洗濯機の背後から2足も出てきた
45. 豚骨からスープを作る
46. 昼の12時まで寝る
47. スマブラSPECIAL全キャラ解放する
48. 梅酒を漬ける
49. 整体に行く
50. 4コマ漫画を描く
51. webアプリを作る
52. 3Pシュートを決める
53. 街で有名人を見かける
天皇も有名人ですから

54. 弁当を作る
55. 潮干狩りをする
56. 歴史の本を読む
57. 開店凸する
58. ジャグリングをする
59. ルービックキューブを6面そろえる
60. 大声を出す
61. 知人を家に招く
62. 祖父母に会う
63. ボードゲームを遊ぶ
64. デカい魚を捌く
65. 納豆3パックを一気に食べる
66. 手持ち花火をする
67. 竹を割って流しそうめんをする
68. 火を起こす
69. 落とし物を届ける
70. 記念館に行く
71. 自宅で食べ比べ or 飲み比べをする
72. 魔改造する
73. りんごとはちみつでカレーを作る
74. かつお出汁をとる
75. 行ったことのないチェーン店にいく
76. 自販機で飲み物以外を買う
77. ファミレスで一番高いメニューを頼む
78. ボランティアに参加する
79. 靴を磨く
80. 長期休暇に日記をつける
81. 外でサンドイッチを食べる
82. 気球に乗る
83. 和菓子を作る
84. カクテルを作る
85. オーブンのスチーム機能を使う
86. 部屋にポスターを貼る
87. 自転車に乗る
折り畳みを購入して、数年ぶりに乗った

88. YouTubeにコメントする
89. 映画館でポップコーンを買う
90. 指笛を鳴らす
91. 王将で餃子とビールだけ注文する
92. 短編集を読む
93. コーヒーを焙煎する
94. 1日野菜を食べない
95. 観葉植物を飾る
96. 果物狩りをする
97. 雪だるまを作る
98. 七味唐辛子を自作する
99. 電子工作する
100. 行ったことのない県に行く
【ポケモンSV】ポリゴン2の「2」って全角?半角?(表記揺れ)
自分のアプリ内でバトルデータベースの公開データを使おうとしたときに、全角・半角の表記違いで引っかかった
細かいところだが疎かにすると後で書き直す羽目になる(実際になっている)ので、表記がどうなっているのか備忘録として残しておく
ポケモンの名前
ポケ徹は半角英数字を使用している
| ポリゴン2 | ポリゴンZ | タイプ:ヌル | |
|---|---|---|---|
| 公式 | 全角 | 全角 | 全角 | 
| バトルDB | 全角 | 全角 | 全角 | 
| ポケ徹 | 半角 | 半角 | 全角 | 
わざ
ポケ徹は半角英数字を使用している
| 10まんボルト | テクスチャー2 | Vジェネレート | DDラリアット | 10まんばりき | Gのちから | 3ぼんのや | |
|---|---|---|---|---|---|---|---|
| 公式 | 全角 | 全角 | 全角 | 全角 | 全角 | 全角 | 全角 | 
| バトルDB | 全角 | 全角 | 全角 | 全角 | 全角 | 全角 | 全角 | 
| ポケ徹 | 半角 | 半角 | 半角 | 半角 | 半角 | 半角 | 半角 | 
URL, json
ポケ徹の数字(X)は途中まで図鑑番号だが、4桁手前では図鑑番号から少しずれている
フォルム違いの添え字(S)の決め方もポケモンによって異なるように見える
| 基本フォルム | 派生フォルム | |
|---|---|---|
| 公式(図鑑サイト) | XXXX | XXXX-N | 
| 公式(HOMEデータ) | XXXX | XXXX_NNN | 
| バトルDB | XXXX-00 | XXXX-NN | 
| ポケ徹 | nXXXX | nXXXXS | 
X, N: 半角0~9
S: 半角a~z
外国語
公式は全て半角
結論
公式(ポケモンHOMEの内部データ)の表記に従っていれば、バトルデータベースとの互換には困らない
【ポケモンSV】上位構築のポケモンの型を機械的に分類する
動機
以前紹介した対戦シミュレータを使っていると、相手のポケモンの型を予想・補完しないといけない場面が結構あります。これまではポケモンHOMEの情報をもとにほぼランダムで生成していましたが、どうしてもリアリティに欠けるので、実際に結果を残した構築から型を抽出できないかと考えました。
シミュレータ以外にも幅広く使えそうな情報が得られたので、簡単ですが共有したいと思います。
データ収集
バトルデータベースが公開している情報と、同サイトにリンクが張られている構築記事の情報を組み合わせて、型の分類に必要なデータを作ります。
バトルデータベース
ありがたいことに集計結果が公開されており、json/csv形式でダウンロードできます。ただ、ポケモンの特性/技/ステータス関連の情報がないため、これだけで型を識別するのは難しそうです。


構築記事
さらにありがたいことに、バトルデータベースには過去シーズンの1000位以内の構築記事がまとめられています。構築記事を見ればだいたいわかるはずなので、スクレイピングして情報を取り出します。記事の文章は人によって書き方が違っていて解析が面倒なので、今回はレンタルパーティのスクショを探して、画像認識で特性と技を取得しました。

結果
シーズン22のシングルバトルのデータを確認します。執筆時点でバトルデータベースに公開されている構築は216件ですが、画像を取得できた構築は129件と6割程度でした。それでも774体のポケモンのデータが集まりました。129件の構築で最も使われたポケモンはブリジュラス(71件)で、カイリューガチグマがそのすぐ下にいますね。

ブリジュラスの統計

ここまではバトルデータベースの分析ボタンを押せば見れるので、構築記事から集めた技も確認します。エレクトロビームが50%近く採用されており、ランクマッチ全体の統計(30%)よりもパワフルハーブ型が多かったようです。

型分類
単純な例として、「型 = 特性とアイテムの組み合わせ」と定義して分類すると次のようになりました。パワフルハーブ型が5割弱と主流で、そのほとんどが頑丈で採用されています。持久力+回復アイテム型も合計すれば3割に達します。母数は少ないですが、アイテムと特性は住み分けられているようです。

型ごとの技の採用率をプロットしました。 私は昔育てたCSベースのブリジュラスに風船を持たせて使っていたのですが、風船型はステロを撒くのがいいみたいです。

多数派の頑丈パワフルハーブ型ブリジュラスと同時採用されたポケモンの分布も見てみます。珠ミミッキュがずば抜けて多いことや、ほかのメンツからしても対面構築が多いのかもしれません。

無限に遊べそうなので今回はここで終わります。
最後に
ポケモンの型に着目することで、統計データをより実践的に解釈できるように思います。
自動で集められなかった記事や過去の類似シーズンも参照すれば、なかなかリッチなデータベースになりそうです。データを効率よく集めるためにスクレイピングの精度を上げたいところですが、Chat GPTにお願いしたところ性格くらいは拾えたので、いつか(無料になる日がきたら)生成AIなども活用してみたいですね。
また、型どうしの横のつながりを可視化すれば、構築アドバイザーのような新しいアプリができるかもしれません。個人的には、こういった解析を本家バトルデータベースのサイト上でやれたら嬉しいのですが......もしこの記事をご覧になっていたらぜひ検討していただけないでしょうか?
冒頭にあるように一連のソースコードを公開していますので、興味のある方はぜひご自身で解析してみてください。
【ポケモンSV】の対戦システムをPythonで再現して実機AIを作る (後編:実機Bot)
前編をお読みいただいた前提で説明するため、未読の方はまずこちらをご覧ください。
目次
概要
対戦シミュレーションのBattleクラスをベースに、以下の機能を追加してBotモジュールを構築します。
- ゲーム画面を解析して情報を取得する
- シミュレータに情報を渡して、方策関数からコマンドをもらう
- Switchにコマンドを送ってゲームを操作する

必要なもの
ポケモンSVは日本語ROMを前提にしています。
Switchを遠隔操作するnxbtモジュールを使うにはLinux環境が必要です。
VirtualBoxの仮想環境でも動くらしいのですが、私はvagrant upのところで躓いて、挫折してラズパイ5を購入しました。
github.com
私の環境
- PC - Raspberry Pi 5 8GB (1.5万円)
- キャプチャーボード - HSV320 (4,700円) Full HD で出力できればなんでもOK
- Python 3.11.2
nxbtのインストールでトラブルが頻発しているようなので、可能であれば、まず手持ちのLinux環境で試してみてください。
私は当初ラズパイ3Bで開発していましたが、処理能力が低く文字認識に時間がかかりすぎたため、奮発してラズパイ5に乗り換えました。
なお、ラズパイ3Bでは内部のBluetoothアダプタを使うと通信できず、代わりにUSBドングルを外部接続する必要がありました。VirtualBoxでも同様だそうです。
nxbtのインストール
nxbtはsudoでインストールする必要があるため、あらかじめpythonの仮想環境を構築しておきます。
次に、こちらを参考にnxbtをインストールしてください。
Switchのコントローラ接続画面に移動して、ターミナルに以下のコマンドを入力します。
cd Pokepyのソースコードのディレクトリ
sudo 仮想環境のパス/bin/python ex11_nxbt_connection.py
見慣れないプロコンが接続されたら正しくインストールできています。

nxbtはプロコンに偽装するため、Switchに接続する前に本物のプロコンの接続を解除する必要があります。無線接続している場合は、USB-Cポートの隣にある小さいボタンを押し込むことで切断できます。
事前準備
キャプチャーボードの確認
ターミナルに以下を入力します。
v4l2-ctl --list-devices
出力結果
(略)
MiraBox Capture: MiraBox Captur (usb-xhci-hcd.1-2.4):
        /dev/video1
        /dev/video2
        /dev/media0
表示されたVide ID (上の例だと1または2) をconfig.txtに書いておきます。
VideoID 1
キャプチャーボードを差し替えるとVideo IDが変わってしまうことがあるので、キャプボの接続に失敗することがあれば設定を見直してみてください。
遅延時間の測定
必須ではありませんが、画面を移動するためにコマンドを入力してから、遷移後の画面をキャプチャできるようになるまでの時間を計測しておくと、Botの動作精度や速度改善に役立ちます。
野生ポケモンとの戦闘画面に移動して、次のスクリプトを実行します。
sudo 仮想環境のパス/bin/python ex12_latency_meas.py

パーティ登録
実戦で使うパーティを登録します。
ボックス画面の手持ちまたはバトルボックスにパーティをセットし、右側にステータスを表示した状態で以下を実行します。
sudo 仮想環境のパス/bin/python ex13_party_registration.py

Bot構築
前編で例にあげた方策関数をそのまま流用した、Botのサンプルスクリプトを用意しました。
"""ex14_sample_bot.py""" from pokepy.pokebot import * from distutils.util import strtobool import random import sys # Pokebotクラスを継承 class MyBot(Pokebot): def __init__(self): super().__init__() def selection_command(self, player=0) -> list[int]: """{player}の選出画面で呼ばれる方策関数 n=0~5 : パーティのn番目のポケモンを選出 選出する順番に数字を格納したリストを返す """ # ランダム選出 return random.sample( list(range(len(self.party[player]))), 3 ) def battle_command(self, player): """{player}のターン開始時に呼ばれる方策関数 ex4_bruteforce_1on1.py から流用 """ (略) # スコアが最も高いコマンドを選ぶ return available_commands_list[0][scores.index(max(scores))] def change_command(self, player: int) -> int: """{player}の任意交代時に呼ばれる方策関数""" # ランダム交代 return random.choice(self.available_commands(player, phase='change')) def score(self, player: int) -> float: """盤面の評価値を返す""" # 例: TODスコアの比 return (self.TOD_score(player) + 1e-3) / (self.TOD_score(not player) + 1e-3) # ライブラリの初期化 Pokemon.init(season=None) # Botを生成、実行 bot = MyBot() bot.main_loop(vs_NPC=strtobool(sys.argv[1]))
最初にPokebotクラスを継承してBotを作成します。 PokebotクラスはBattleクラスを継承したものです。
前編のシミュレータと比べると、選出画面でコマンドを返す方策関数 selection_command() が追加されていますが、それ以外はほぼ変わりません。
試運転 vs NPC
作成したBotで学校最強大会を周回してみます。
大会に参加した状態で、ターミナルに以下を入力します。
sudo 仮想環境のパス/bin/python ex14_sample_bot.py 1
引数に1を指定すると対NPC戦のモードで走ります。簡易的なデバッグに便利です。
対人戦
フリーマッチまたはランクマッチの待機画面に移動して、ターミナルに以下を入力します。
sudo 仮想環境のパス/bin/python ex14_sample_bot.py 0
引数に0を渡すと対人戦モードで動作します。
現状の課題
1. 表示テキストの誤読が多い
対戦中、画面に表示されるテキストから相手の技やアイテムを取得していますが、 全く関係のないテキストを誤読することが多いです。 これは、ノイズとなるテキストを手動で除外しているのですが、文章が多岐にわたり全てをカバーできていないためです。
2. ダメージを観測していない
前述のテキスト解析を優先して、ダメージを観測する仕組みをまだ実装できていません。 実際にやると、どこまでが技によるダメージでどこからが回復なのか、など識別に多少苦労しそうです。
3. たまに止まる
...
今後の展望
前述の課題に取り組みつつ、並列処理による高速化も試してみたいですね。
【ポケモンSV】の対戦システムをPythonで再現して実機AIを作る (前編:対戦シミュレータ)
(poke-envの存在を知らずに作ってしまった...
手番以降の盤面の部分探索ができる、実機対戦に対応している点で差別化できていると信じたい)
【ソースコード】対戦シミュレータ + 実機対戦Bot github.com
【ソースコード】ランクマッチの使用率を取得 github.com
目次
できること
- 対戦シミュレーション
- 致死率計算 (いわゆるダメージ計算)
- ポケモンSVの実機制御
概要
「強いAIを作ったので公開します!」という趣旨ではなく、あくまでコンピュータに実機で対戦させる仕組みを作ってみたというものです。
今回の内容は2つの記事に分けて紹介します。
はじめに、コンピュータが先読みできるように、ポケモンSVの対戦システムを再現するシミュレータを導入します。
続いて、このシミュレータに画面解析やコマンド入力などの機能を追加して、実機ポケモンSV上で自立して対戦するBotを作ります。
必要なもの
シミュレータを使うには、
- PythonをインストールしたPC
さらにBotを作るには、
PCからSwitchを操作するためにnxbtというライブラリを使用しますが、これはLinux(仮想)環境でしか動作しないようです。
形成判断AIのようにSwitchを操作しないアプリを作るのであれば、どのOSでもよいと思います。
シミュレータの導入
筆者の環境
- Windows11 64bit
- Python 3.11.9
- Visual Studio Code 1.93.1
基本的な使い方
2匹のポケモンを戦わせてみます。
"""ex_random_1on1.py""" from pokepy.pokemon import * # ライブラリの初期化 Pokemon.init() # Battleクラスのインスタンスを生成 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""" (略) # ポケモンを3匹ずつ生成して選出に追加 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クラスはポケモンの個体や関連情報を表現します。
ポケモンの生成
ポケモンを生成して中身を確認してみます。
"""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(f'状態変化\t{p.condition}') 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""" # Pokemonクラスを継承 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の例では、ポケモンはランダムに行動していました。


先読み
シンプルな先読みの例として「選択可能なコマンドをすべて実行してみて、一番マシな盤面になるコマンドを選ぶ」ような方策関数を実装してみます。
"""ex4_bruteforce_1on1.py""" from pokepy.pokemon import * # Battleクラスを継承 class MyBattle(Battle): def __init__(self): super().__init__() def battle_command(self, player): """{player}のターン開始時に呼ばれる方策関数""" # プレイヤー視点の仮想盤面を生成 blinded = self.clone(player) # 両プレイヤーの選択可能なコマンドの一覧を取得 # 自分: player (= 0 or 1), 相手: not player (= 1 or 0) 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])) # 行動が有効なら盤面の評価値を計算し、無効なら0を記録する 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: """盤面の評価値を返す""" # 例: TODスコアの比 return (self.TOD_score(player) + 1e-3) / (self.TOD_score(not player) + 1e-3) # ライブラリの初期化 Pokemon.init() # Battleクラスのインスタンスを生成 battle = MyBattle() (略)
方策関数が呼ばれている間、コマンド入力待ちのためターン処理は中断されています。
clone() メソッドで現在の盤面を複製し、得られた仮想盤面にコマンドを渡してターンを再開します。
その後、仮想盤面の終状態の評価値に基づいてコマンドを評価しています。
盤面の複製については次節で詳しく見ていきます。
score() メソッドで利用している TOD_score とは、内部的に試合の勝敗判定に用いられる評価値で、次のように定義されます。


ポケモンが全滅するとTOD_scoreは0になり、判定勝負ではTODスコアの大きいプレイヤーが勝利します。
交代の方策関数 change_command() でも同様の探索を行うことができます。
"""ex5_bruteforce_3on3.py""" from pokepy.pokemon import * # Battleクラスを継承 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\tPlayer {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\tPlayer {player} {battle.log[pl]}') # 交代後、さらにターンを進めることも可能 #battle.proceed() # 盤面の評価値を記録 scores.append(battle.score(player)) print('\t'+'-'*76) # スコアが最も高いコマンドを返す return available_commands[scores.index(max(scores))] def score(self, player: int) -> float: """盤面の評価値を返す""" # 例: TODスコアの比 return (self.TOD_score(player) + 1e-3) / (self.TOD_score(not player) + 1e-3) # ライブラリの初期化 Pokemon.init() # Battleクラスのインスタンスを生成 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 = 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.nature = 'いじっぱり' #p1.ability = '' #p1.item = 'いのちのたま' #p1.Ttype, p1.terastal = 'ステラ', True #p1.rank = [0, 0, 0, 0, 0, 0] #p1.ailment = 'BRN' p1.show() p2 = Pokemon('ガチグマ(アカツキ)', use_template=False) #p2.nature = 'ずぶとい' #p2.ability = '' #p2.item = 'オボンのみ' #p2.Ttype, p2.terastal = 'フェアリー', True #p2.rank = [0, 0, 0, 0, 0, 0] #p2.ailment = 'PSN' #p2.condition['shiozuke'] = 1 p2.show() # 攻撃側のプレイヤー player = 0 # 攻撃技 move_list = ['スケイルショット'] #move_list = ['スケイルショット','じしん'] # 複数なら加算ダメ計 print(move_list) n_hit = 5 # 連続技のヒット数 # Battleインスタンスを生成 battle = Battle() battle.pokemon = [p1, p2] # 盤面の状況を設定 #battle.condition['sandstorm'] = 1 #battle.condition['glassfield'] = 1 #battle.condition['reflector'] = [1, 1] # 致死率計算 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: # Clone()したときに相手ポケモンの情報を補完する def complement_move(self, player: int) -> str: # Clone()したときに相手の技選択を補完する def complement_change_command(self, player: int) -> int: # Clone()したときに相手の交代先を補完する def estimate_attack(self, player: int, name: str, status_index: int, recursive: bool=True) -> bool: # これまでに発生したダメージから、相手のA/C実数値と補正アイテムを推定する def estimate_defence(self, player: int, name: str, status_index: int, recursive: bool=True) -> bool: # これまでに発生したダメージから、相手のH/B/D実数値と補正アイテムを推定する
最後の相手の攻守を推定するメソッドは、方策関数の冒頭などで使うとよいでしょう。
後編へ
【ポケモンSV】ポケモンHOME APIでランクマッチの使用率を取得してみる
はじめに
ポケモンSVのランクマッチ用に、自動ダメージ計算サイトを作りました。
pbasv.cloudfree.jp
別の記事で紹介していますが、このサイトはポケモンHOMEが提供するランクマッチの統計データがないと機能しません。
ですが、株式会社ポケモンの利用規約を読むと、
5.ユーザーの権利および制約について 本規約は、お客様個人に対し、本サービスのコンテンツを個人的かつ非商業的な形で、お客様がご自宅において利用することに限り許諾するものです。お客様は、如何なる場合であっても、次のような行為をしてはならないものとします。 (中略) (v) 如何なる理由であれ、大量のコンテンツをデータベースにダウンロードすること
とあり、上記の自動ダメ計サイトや、ポケモン対戦をサポートするサードパーティツールの多くは(この記事含め)規約に則っているとは言いがたいです。
ポケモンホームの情報等を利用したツールを作成する際は、少なくとも商用利用にならないよう気を付けたいと思います。
ポケモンHOMEの統計データを取得する
やり方は剣盾時代に確立されているはずなので、とりあえずググってみる。
1. 剣盾のランクマ情報の取得方法を説明している記事
okuokuch.hatenablog.com
2. SV用のURLも紹介している記事
xn--lcss68alkav93n.com
要点としては、まず全シーズンの情報を取得する。
headers = {
    'accept': 'application/json, text/javascript, */*; q=0.01',
    'countrycode': '304',
    'authorization': 'Bearer',
    'langcode': '1',
    'user-agent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Mobile Safari/537.36',
    'content-type': 'application/json',
}
data = '{"soft":"Sw"}'
#url = 'https://api.battle.pokemon-home.com/cbd/competition/rankmatch/list' #剣盾
url = 'https://api.battle.pokemon-home.com/tt/cbd/competition/rankmatch/list' #SV
response = requests.post(url, headers=headers, data=data)
次に、取得した全シーズンの情報から特定のシーズンのデータが保存されているパスのID(id,rst,ts1,ts2)を得る。
terms = [] for season in data: #このdataは先ほどのresponse.text()を辞書型に格納したもの for id in data[season]: if data[season][id]['rule'] == 0: #0:シングル, 1:ダブル terms.append({'id': id, 'rst': data[season][id]['rst'], 'ts1': data[season][id]['ts1'], 'ts2': data[season][id]['ts2']}) term = terms[0] # 0:最新, 1:前シーズン, 2:前々シーズン, ... id = str(term['id']) rst = str(term['rst']) ts1 = str(term['ts1']) ts2 = str(term['ts2'])
これをもとに、ポケモン使用率ランキングや、
#url = 'https://resource.pokemon-home.com/battledata/ranking/'+id+'/'+rst+'/'+ts2+'/pokemon' # 剣盾 url = 'https://resource.pokemon-home.com/battledata/ranking/scvi/'+id+'/'+rst+'/'+ts2+'/pokemon' # SV response = requests.get(url, headers=headers)
各ポケモンの技や持ち物の採用率といった個別データを取得する。
for x in range(1,7): #url = 'https://resource.pokemon-home.com/battledata/ranking/'+id+'/'+rst+'/'+ts2+'/pdetail-'+str(x) # 剣盾 url = 'https://resource.pokemon-home.com/battledata/ranking/scvi/'+id+'/'+rst+'/'+ts2+'/pdetail-'+str(x) # SV response = requests.get(url, headers=headers)
取得したデータを確認すると、


マッピングデータを探す
jsonファイルを取得したURLをブラウザで検索していると、剣盾のポケモンホームのページ?が出てきた。
resource.pokemon-home.com
このページのhtmlの31行目に、
appjs.src = './js/bundle.js?v='+scriptVer;
とあり、bundle.jsというJavascriptファイルを読み込んでいる。

bundle.jsには全国図鑑・タイプ番号・技番号などが直書きされている。
先ほどAPIで取得したjsonファイルは、このbundle.jsと照らし合わせて読み取ることができる。
...と思いきや、アイテムの対応コードが書かれていない。
bundle.jsをもう少し読むと、
getItemnamejson(e,t){this.getjson(e,t,"./json/itemname",{itemname:{}})}  
という関数が定義されており、アイテム名を取得するためにさらに外部ファイルを参照しているようだ。
目的のjsonファイル名がよくわからなかったので、HTTPリクエスト追跡ツールで探してみた。 rakko.tools
上記のサイトに例えばザシアンの詳細ページのURLを入力すると、HTTPリクエスト一覧に
https://resource.pokemon-home.com/battledata/json/itemname_ja.json?v=2861801
と出力され、アイテム番号が itemname_ja.json に格納されていることがわかる。

bundle.js内で参照しているjsonファイルは全部で5つ。
- アイテム番号 https://resource.pokemon-home.com/battledata/json/itemname_ja.json
- フォルム番号 https://resource.pokemon-home.com/battledata/json/zkn_form_ja.json
- 特性の説明 https://resource.pokemon-home.com/battledata/json/tokuseiinfo_ja.json
- わざの説明 https://resource.pokemon-home.com/battledata/json/wazainfo_ja.json
- アイテムの説明 https://resource.pokemon-home.com/battledata/json/iteminfo_ja.json
最後に
APIを叩いて取得したデータをJSON形式で保存するまでのPythonスクリプト
import requests import json ############ シーズン情報を取得 ############ headers = { 'accept': 'application/json, text/javascript, */*; q=0.01', 'countrycode': '304', 'authorization': 'Bearer', 'langcode': '1', 'user-agent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Mobile Safari/537.36', 'content-type': 'application/json', } data = '{"soft":"Sw"}' #url = 'https://api.battle.pokemon-home.com/cbd/competition/rankmatch/list' #剣盾 url = 'https://api.battle.pokemon-home.com/tt/cbd/competition/rankmatch/list' #SV response = requests.post(url, headers=headers, data=data) with open('season.json', 'w', encoding='UTF-8') as fout: fout.write(response.text) with open('season.json', encoding = 'utf-8') as fin: data = json.load(fin)['list'] terms = [] for season in data: for id in data[season]: if data[season][id]['rule'] == 0: #0:シングル, 1:ダブル terms.append({'id': id, 'rst': data[season][id]['rst'], 'ts1': data[season][id]['ts1'], 'ts2': data[season][id]['ts2']}) term = terms[0] # 0:最新, 1:前シーズン, 2:前々シーズン, ... id = str(term['id']) rst = str(term['rst']) ts1 = str(term['ts1']) ts2 = str(term['ts2']) headers = { 'user-agent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Mobile Safari/537.36', 'content-type': 'application/json', } ############ ポケモンの使用率を取得 ############ #url = 'https://resource.pokemon-home.com/battledata/ranking/'+id+'/'+rst+'/'+ts2+'/pokemon' # 剣盾 url = 'https://resource.pokemon-home.com/battledata/ranking/scvi/'+id+'/'+rst+'/'+ts2+'/pokemon' # SV response = requests.get(url, headers=headers) with open('pokemon_ranking.json', 'w', encoding='UTF-8') as fout: fout.write(response.text) ############ 各ポケモンの個別データを取得 ############ for x in range(1,7): #url = 'https://resource.pokemon-home.com/battledata/ranking/'+id+'/'+rst+'/'+ts2+'/pdetail-'+str(x) # 剣盾 url = 'https://resource.pokemon-home.com/battledata/ranking/scvi/'+id+'/'+rst+'/'+ts2+'/pdetail-'+str(x) # SV response = requests.get(url, headers=headers) with open('pokemon'+str(x)+'.json', 'w', encoding='UTF-8') as fout: fout.write(response.text)
【ポケモンSV】自動ダメージ計算サイトの仕組みと高速化について
はじめに
ポケモンSV向けに自動ダメージ計算サイトを作りました。
キャプチャーボードを接続したPCからアクセスすると、対戦中にゲーム画面を解析してダメージ計算してくれます。
サイトのソースコードも公開しています。
1年前くらいにPythonで同じようなアプリを作ったのですが、スクリプトや実行ファイルは配布に不向きでメンテナンスも面倒だったため、ウェブ版を作りたいと考えていました。
ただ、ウェブ開発の経験がなかったこともあり、なかなか着手できず、昨年末に参考書を購入してようやく作り始めました。
Pythonアプリの下地があったため、1ヶ月あればできるだろうと舐めてかかった結果、土日をほぼすべて費やした上で3ヶ月もかかってしまいました...
せっかくなので、アプリの仕組みや工夫した点について記事にしようと思います。
ユーザーや、これからアプリを開発をしたい方にとって参考になれば幸いです。
コンセプト
自動ダメージ計算アプリ
私がポケモン対戦をまともにやったのはXY以来で、SVもかなりのライトユーザーです。
移り変わる環境について行くのは大変で、特にダメージ感覚がまったくありません。
とはいえ、対戦中にダメージ計算を叩くのも面倒なので、画像解析して自動化しようと考えました。
対戦中にアプリを使うこと自体が負担にならないよう、以下の点に注意して設計しました。
- キャプチャ映像を表示する (ゲーム画面とアプリ画面を往復しなくてよい)
- キャプチャ映像の上にモノを置かない (背景が動くと目が疲れる)
- 自分と相手のパーティを常に表示する (対戦中に確認する手間を減らす、死に出し時に確認できる)
- ダメージ計算と致死率は数字で明確化する。情報量は色濃度と表示桁数で調整
- 相手の持ち物・テラスといった未知の情報は、数を限定して表示する (情報過多にしない)
- 種族値などの慣れてくると分かる情報は、カーソルを合わせた時に注釈として表示する (情報過多にしない)

ポケモン管理アプリ
ポケモンを一から育成する場合、仮想敵を考えてステータスを調整することが多いです。
そのため、育成情報と仮想敵とのダメージ計算は常にセットで管理したいと考えました。
前述の自動ダメージ計算アプリと併用する前提のため、PCの大画面で効率よく管理できるように設計しました。
- 育成情報とダメージ計算を同時に表示する
- 攻守問わず、複数のダメージを並列計算
- 耐久調整 (◯◯耐え) の自動化
- 最低限のダメージ加算機能

使用言語
一部のバックエンドを除き、アプリの大部分は Javascript で実装しました。
そのため、ダメージ計算などの処理はほぼブラウザで処理されています。
標準ライブラリのほかに、以下の外部ライブラリを利用しました。
- Tesseract.js : 文字認識
- OpenCV.js : テンプレートマッチング
- guessLanguage.js : 言語識別
文字認識(OCR)とは、文字通り画像から文字を読み取る機能です。
対戦中の場のポケモンを認識するときなど、多くの場面で利用しています。
汎用的である一方、一度の読み取りに約0.1秒と時間がかかるため、多用するとアプリの使い勝手が悪くなってしまいます。
テンプレートマッチングとは、二つの画像の類似度や類似箇所を判定する機能です。
選出画面での相手パーティの認識や、試合場面の識別などに利用しています。
文字認識と比べて高速ですが、比較対象となるテンプレート画像を用意しておく必要があります。
言語識別については、場のポケモンを認識する際に文字認識と組み合わせて使用しています。
ダメージ計算
ダメージ計算には自作したライブラリを使用しています。
図鑑や技の情報はあらかじめ外部ファイルに集約しておき、起動時に読み込んでいます。
ダメージ計算式 - ポケモン対戦考察まとめWiki|最新世代(スカーレット・バイオレット)
また、検算にはポケモンソルジャー様のダメージ計算サイトを使用しました。
ダメージ計算の高速化
ダメージには乱数があり、85%, 86%, ..., 100%の16通りの中からランダムに選ばれます。
ダメージによる致死率を正確に求めるためには、すべてのパターンを網羅しなければいけません。
1発だけなら16通りで済みますが、例えばスケショ5発のダメージを計算すると16の5乗 (約100万) 通りに分岐します。
分岐が増えるに従って計算時間も増加します。
それでも5発程度であれば計算可能ですが、スケショ+神速といった加算計算や、スライドバーでステータスを変えながら繰り返し計算する場合、この計算量はもはや現実的ではありません。
開発途中で確認したところ、6発計算 (16の6乗) に0.5秒かかり、7発計算しようとするとブラウザが停止しました。
これの対策として、実際には16通りの中に重複するダメージが含まれることを利用して、同じダメージをまとめて計算しています。
ダメージの重複は、1発あたりのダメージが小さい連続技において特に顕著になります。
例えば、無振カイリューが無振オーガポンにスケショ1発で与えるダメージは22, 24, 25, 27の4通りしかなく、5発でも24通り(全分岐計算時の4万分の1以下)の計算量で済みます。
実装については、配列ではなく、{"ダメージ値" : 分岐の数} の連想配列 (Pythonでいう辞書型) として扱っています。
技によるダメージを計算した後の致死率計算では、防御側のポケモンのHPの分岐も {"残りHP" : 分岐の数} として管理します。
木の実による回復
前述の方法だけでは、あるHPに至る経緯は保持されないため、オボンの実などの回復実をすでに使ったかどうか判別できません。
そこで、
- {"100" : 3} → 木の実を保持しているHP100の分岐が3通りある
- {"100.0" : 3} → 木の実を保持していないHP100の分岐が3通りある
のようにHPを表すキーを少数表示にして区別しています。
キーを整数化すれば同等に扱うことができるため、実装を煩雑化せずに回復実の有無を識別できます。
この方法を用いて、木の実の発動条件を考慮して正確な確定数を計算しています。
アプリの仕組み
データの保存
ユーザーが登録したポケモンや最後に入力した情報は記録され、アプリを再開したときに読み込まれます。
データはブラウザのローカルストレージに保存されているため、アプリにログインしてアカウントで紐づける必要がありません。
一方で、異なるブラウザソフトや端末間では情報が共有されないデメリットや、Cookieを削除するとデータも消えるリスクもあります。
この対策として、ポケモン管理アプリには、登録したポケモンをjson形式で出力・入力できるバックアップ機能を実装しています。
キャプチャ映像の取得・描画
キャプボもカメラとして扱われるため、navigator.mediaDevices プロパティを使って映像を取得できます。
html の video 要素で描画すると画面のアスペクト比がなぜか微妙に崩れてしまうため、まわりくどいですが video 要素の映像を canvas 要素に再描画しています。
画面からポケモン読み込み
ありがたいことに、ボックスの右側のステータス表示で情報が完結しているため、これを解析するだけでポケモンを読み込めます。
性格は六角形の頂点にある◯印をテンプレートマッチングで識別し、それ以外は文字認識しています。

試合場面の判定
自動ダメージ計算アプリの起動中は、キャプチャ映像を一定時間ごとに確認し、現在のゲームの場面を以下の3つに分類します。
- 選出画面
- 対戦画面
- 該当なし
選出画面は、残り時間の時計マークの有無で判定しています。

対戦画面は、自分のポケモンの「Lv.」という文字の有無で判定しています。

選出画面での処理
選出画面に移行すると、まず試合をリセットします。
次に、相手のパーティを読み込みます。
ポケモンの立ち絵をテンプレート画像と比較して識別しますが、この時、
することで高速化しています。
画面のロードが間に合わず結果がおかしくなる場合があるため、2回連続で同じ結果を得られるまで繰り返し読み取ります。
相手パーティが読み込めたら、自分と相手のポケモンの相性を評価します。
評価値は以下の式で与えています。
自分と相手の評価が相互に影響するため、有効打が多い相手に有利なポケモンがより評価されます。
攻撃技が少ない受けポケモンは評価されにくいですが、そもそも受け性能の評価は複雑なため諦めました。
試合が始まるまで繰り返し評価を行うことで評価値を収束させます。
対戦画面での処理
対戦画面に移行したら、試合時間のタイマーを起動します。
以降の処理は、場のポケモンが変わるたびに繰り返し行います。
まず、場に出ているポケモンの名前を文字認識します。
文字認識の関数は言語を指定して呼び出しますが、ポケモンの名前は日本語とは限りません。
そこで、まず日本語と仮定して読み取ります。
実際の名前がハングルだとうまく読み取れないため、一度目の文字認識で該当するポケモンがいなければ、ハングルを指定して再び読み取ります。
次に、場のポケモンのHPを読み取ります。
自分のポケモンについては、HPバー内に書かれている文字を読み取っています。
相手のポケモンの正確なHPはわからないため、HPバーの有色箇所のピクセル数を数えて割合に換算しています。
場のポケモンとHPをすべて読み終えたらダメージ計算を行います。
被ダメージ計算では公開されている使用率上位の技を考慮しています。
バランスチェック機能
使用率上位のポケモンのうち、今のパーティが苦手とする相手をハイライトできます。
攻・守をクリックすると、すべての相手に対してダメージ計算を行い、与えるダメージ割合の合計値を評価します。
受け性能の評価では、ダメージを技の使用率で重みづけしたものの合計で評価しています。

最後に
今後もバグ修正などは細々と続けていきます。
レギュFを全くプレイできていないので、伝説環境が始まる前にランクマ復帰したいですね。