想定読者: プログラミングを学習したことがある人
Pokemon Showdown! という非公式の対戦シミュレーターがあります。
このWebアプリケーションのソースコードはGithubで公開されており(オープンソース)、誰でも閲覧、貢献(プルリクエストの送信)ができます。大きく分けると、以下の2つです。
- サーバー (対戦受付サーバー、対戦シミュレーター)
- クライアント(WebサイトのHTML, CSSなど※)
※詳しく言うと、preactというReact系のフレームワークを使用したシングルページアプリケーションの模様
このうちサーバーの方には、シミュレーター機能を単体でライブラリとして使用するためのAPIが実装されています。内訳は以下の通りです。
JavaScript用ライブラリ
・対戦シミュレーター…(a)
・Teamテキストの変換API
・ポケモンの種族情報(Dex)の取得API
標準入出力経由(→言語不問)で操作するコマンドラインツール
・対戦シミュレーター
・Teamテキストの変換ツール
参考:
https://github.com/smogon/pokemon-showdown/blob/master/sim/README.md
https://github.com/smogon/pokemon-showdown/blob/master/COMMANDLINE.md
今回は、(a)のJavaScript用対戦シミュレーターライブラリを使用してみることにします。初心者向け解説になっています。
サンプルコードはこちらにあるので、使用例だけ見たい方はこちら。
github.com
Node.jsのインストール
Node.jsは、サーバーサイドJavaScriptと呼ばれるJavaScriptの実行環境になります。
JavaScriptは通常、Webブラウザを使って、HTMLから呼び出して実行するスクリプト言語ですが、それを通常のプログラミング言語と同様に、ローカルPCやサーバー内でも動かせるようにしたものです。
Pokemon ShowdownのサーバーソースコードはこのNode.jsで書かれているので、ローカルで実行するためにはインストールが必要になります。
Pythonのように、スクリプト言語を一つPCにインストールするようなものだと思っておけばいいです。
nodejs.org
Windowsの場合、インストーラーから簡単にインストールできるので、とりあえずLTSバージョンをDL→インストールしておけばいいでしょう。
単に便利というだけで強制はしないのですが、ここから先はVisual Studio Code(以下VS Code)の使用をオススメします。(説明の上ではVS Codeを使っている前提で進めます)。
VS Codeは非常に高機能なテキストエディタです。厳密にはVisual studio (Codeでない方)、EclipseのようなIDEではないのですが、IDEだと思っても特に不都合はありません。
特にNode.js開発の場合、頻繁にターミナル(コマンドプロンプト)を使うので、ターミナルウィンドウ付きのVS Codeが便利です。
code.visualstudio.com
テスト用Node.jsアプリケーションの作成
適当な空フォルダを作って、そのフォルダをVS Codeで開きます。上メニュー>ターミナル>新しいターミナル からターミナルウィンドウも開いておきましょう。
ターミナルに以下のように入力し、Enterを押します。
npm init -y
package.jsonというNode.jsアプリケーション用の設定ファイルが作成されます。
次に、メインプログラムを記述するapp.js (名前はなんでも大丈夫です)を作成しておきます。そして以下のテストコードを入力します。
console.log('Hello world!!')
ターミナルに以下のように入力して実行します。すぐ下の行に"Hello world!!"と表示されれば成功です。ここまででうまくいかなかった場合は、Node.js、VS Codeのインストール、場合によってはPowershellやファイルの操作権限の問題を疑ってください。
node app.js
参考:
https://github.com/smogon/pokemon-showdown/blob/master/sim/SIMULATOR.md
続いてターミナルで以下のコマンドを実行します。
npm install pokemon-showdown
これによりpokemon-showdown(サーバー)のソースコードがnode_modulesフォルダ内にDLされて、自作のコードから参照できるようになります。package.jsonが更新され、package-lock.jsonというファイルが新規作成されます。
これはnpmというパッケージマネージャの機能です(詳細は省きます)。node_modulesフォルダはpackage.jsonをもとにいつでも復元できるものなので、ソースコードをバージョン管理などするときにこのフォルダを含める必要はありません。
説明ページ(上記)にある以下のサンプルコードを動かしてみましょう。app.js内に貼り付けて、同様にnode app.jsで実行します。
const Sim = require('pokemon-showdown');
stream = new Sim.BattleStream();
(async () => {
for await (const output of stream) {
console.log(output);
}
})();
stream.write(`>start {"formatid":"gen7randombattle"}`);
stream.write(`>player p1 {"name":"Alice"}`);
stream.write(`>player p2 {"name":"Bob"}`);
ターミナル出力に以下のような表示が現れれば成功です!7世代ランダムバトル(※)というルールで、対戦インスタンスが作成され、無事1ターン目がスタートしていることが確認できました。
※ シングルバトル、7世代、6vs6、パーティーポケモンはランダムに決定される
対戦ルールの指定、行動選択の指定、、など上のコードをカスタマイズしていくための知識を少し解説します。
BattleStreamの基本
対戦シミュレーターのインターフェイスは、Sim.BattleStreamクラスで定義されたストリームオブジェクト(※)です。
・ストリームへの書き込みをstream.write()で行い、…(1)
・ストリームからのメッセージはstreamオブジェクト自体をasync iterables オブジェクトとみなして取得できます。…(2)
※ Node.js純正のものではなく、独自実装のようです
(1)で書き込むもの
・対戦の初期化
・行動選択
(1)の基本
・送信コマンドの先頭には">"をつける
・> 以降の内容はコマンド種類による
(1)で書き込む内容の例
>start {"formatid":"gen7ou"}
>player p1 {"name":"Alice","team":"insert packed team here"}
>player p2 {"name":"Bob","team":"insert packed team here"}
>p1 team 123456
>p2 team 123456
(2)で取得できるもの
・対戦メッセージ(HP、ターン数など)
・行動選択リクエスト
(2)の基本
・通知種別?→対戦メッセージの順に改行で区切られて送信される。
・対戦メッセージの中身はさらに"|" で区切られ、メッセージ種類→メッセージに応じた内容 と続く。
(2)の例
update
|
|t:|1625406405
|move|p2a: Jolteon|Thunderbolt|p1a: Dialga
|-resisted|p1a: Dialga
|split|p1
|-damage|p1a: Dialga|193/270
|-damage|p1a: Dialga|72/100
...
上記でわかるように、(1)(2)ともにテキストで表現される独自のメッセージフォーマット(protocol)を把握する必要があります。これらは概ね以下のページで説明されています。
https://github.com/smogon/pokemon-showdown/blob/master/sim/SIM-PROTOCOL.md
今回の記事ではこのうち行動選択場面での要点のみを解説し、あとはサンプルコードの掲載で雰囲気を感じ取ってもらうことを想定しています。
行動選択
参考:
https://github.com/smogon/pokemon-showdown/blob/master/sim/SIM-PROTOCOL.md#choice-requests
行動選択のきっかけは、|request|で始まる以下のような行動選択リクエストの受信です。
sideupdate
p2
|request|{"active":[{"moves":[{"move":"Light Screen","id":"lightscreen","pp":48,"maxpp":48,"target":"allySide","disabled":false},{"move":"U-turn","id":"uturn ...
これの|request| 以降のJSONテキストは次のような形をしています。
{
"active": [
{
"moves": [
{
"move": "Light Screen",
"id": "lightscreen",
"pp": 48,
"maxpp": 48,
"target": "allySide",
"disabled": false
},
{
"id": "uturn",
"pp": 32,
"maxpp": 32,
"target": "normal",
"disabled": false
},
{
"move": "Knock Off",
"id": "knockoff",
"pp": 32,
"maxpp": 32,
"target": "normal",
"disabled": false
},
{
"move": "Roost",
"id": "roost",
"pp": 16,
"maxpp": 16,
"target": "self",
"disabled": false
}
]
}
],
"side": {
"name": "Zarel",
"id": "p2",
"pokemon": [
{
"ident": "p2: Ledian",
"details": "Ledian, L83, M",
"condition": "227/227",
"active": true,
"stats": {
"atk": 106,
"def": 131,
"spa": 139,
"spe": 189
},
"moves": [
"lightscreen",
"uturn",
"knockoff",
"roost"
],
"baseAbility": "swarm",
"item": "leftovers",
"pokeball": "pokeball",
"ability": "swarm"
},
{
"ident": "p2: Pyukumuku",
"details": "Pyukumuku, L83, F",
"condition": "227/227",
"active": false,
"stats": {
"atk": 104,
"def": 263,
"spa": 97,
"spe": 56
},
"moves": [
"recover",
"counter",
"lightscreen",
"reflect"
],
"baseAbility": "innardsout",
"item": "lightclay",
"pokeball": "pokeball",
"ability": "innardsout"
},
{
"ident": "p2: Heatmor",
"details": "Heatmor, L83, F",
"condition": "277/277",
"active": false,
"stats": {
"atk": 209,
"def": 157,
"spa": 222,
"spe": 156
},
"moves": [
"fireblast",
"suckerpunch",
"gigadrain",
"focusblast"
],
"baseAbility": "flashfire",
"item": "lifeorb",
"pokeball": "pokeball",
"ability": "flashfire"
},
{
"ident": "p2: Reuniclus",
"details": "Reuniclus, L78, M",
"condition": "300/300",
"active": false,
"stats": {
"atk": 106,
"def": 162,
"spa": 240,
"spe": 92
},
"moves": [
"shadowball",
"recover",
"calmmind",
"psyshock"
],
"baseAbility": "magicguard",
"item": "lifeorb",
"pokeball": "pokeball",
"ability": "magicguard"
},
{
"ident": "p2: Minun",
"details": "Minun, L83, F",
"condition": "235/235",
"active": false,
"stats": {
"atk": 71,
"def": 131,
"spa": 172,
"spe": 205
},
"moves": [
"hiddenpowerice60",
"nastyplot",
"substitute",
"thunderbolt"
],
"baseAbility": "voltabsorb",
"item": "leftovers",
"pokeball": "pokeball",
"ability": "voltabsorb"
},
{
"ident": "p2: Gligar",
"details": "Gligar, L79, M",
"condition": "232/232",
"active": false,
"stats": {
"atk": 164,
"def": 211,
"spa": 101,
"spe": 180
},
"moves": [
"toxic",
"stealthrock",
"roost",
"earthquake"
],
"baseAbility": "hypercutter",
"item": "eviolite",
"pokeball": "pokeball",
"ability": "hypercutter"
}
]
},
"rqid": 3
}
クライアント(今回の自作プログラム)は、このrequestオブジェクトをもとに次の行動をストリームに書き込むことになります。送信コマンドの一例は以下になります。
- >p2 move lightscreen
- >p2 move default
- >p2 switch 2
ただし、上記のrequestオブジェクトの例に含まれていないパターンも多数あり、たとえば以下の状況の際にはrequestオブジェクトに追加の情報が付加されます。関連して送信コマンドに追加のパラメータが必要なケースもあります。この詳細は現状ではマニュアル化されていませんが、いくつかのパターンについては今回のサンプルコード内で実装したので、参考にしてください。
サンプルコード
github.com
執筆時点でのapp.jsの中身を貼り付けておきます。今まで同様に手元のapp.jsに貼り付けて実行すると、結果が表示されると思います。
(サンプルコードをGit Clone or ダウンロードした人向けの実行方法:)
npm install
node app.js
NUM_OF_BATTLESの回数だけ8世代ランダムバトルを繰り返して、最後にお互いの勝利数を出力するプログラムになっています。技選択はランダム、死に出し交代先はその時点で先頭のポケモンです。
現実的に対戦AIのようなものを作る場合は、onChoiceRequest関数内に独自の行動選択ロジックを実装していくことになります。
const Sim = require('pokemon-showdown');
stream = new Sim.BattleStream({keepAlive: true}); // 連続で対戦を行うときはkeepAliveオプションが必要
/*********************************************************************
* *******************************************************************/
const NUM_OF_BATTLES = 10;
const battleFormat = {
formatid:"gen8randombattle" // 対戦ルールを表す内部IDを指定
}
const p1 = {
name:"プレイヤー1"
}
const p2 = {
name:"プレイヤー2"
}
/*********************************************************************
* 集計用 (特別な意味はありません)
* *******************************************************************/
let battleCount = 0;
const winCount = {
[p1.name]: 0,
[p2.name]: 0
}
/*********************************************************************
* BattleStream操作用ロジック
* *******************************************************************/
function writeAndLog(command) {
console.log(`>> ${command}`)
stream.write(`>${command}`) // > を先頭につけるのを忘れずに
}
function startNewBattle() {
console.log(`新規の対戦を開始します(${battleCount + 1}回目)…`);
writeAndLog(`start ${JSON.stringify(battleFormat)}`)
writeAndLog(`player p1 ${JSON.stringify(p1)}`)
writeAndLog(`player p2 ${JSON.stringify(p2)}`)
}
// メガシンカ、ダイマックスも含めて正確に行動選択を制御する場合は、choiceRequestの構造の把握が必要
function onChoiceRequest(choiceRequest) {
const player = choiceRequest.side.id
if (choiceRequest.forceSwitch) { // 死に出し選択状態
writeAndLog(`${player} default`) // 例としてリスト一番上のポケモンに交代
return
} else if (choiceRequest.wait) { // 行動選択なし (例)げきりんの発動中
return
} else if (choiceRequest.teamPreview) { // 選出選択、一部ルールでは存在
// 選出選択が存在するルールでは適当なロジックを書く
return
} else if (!choiceRequest.active) {
return
}
const availableMoves = choiceRequest.active[0].moves.filter(move => !move.disabled)
const rand = Math.floor(Math.random() * availableMoves.length) // 例としてランダム技選択
const move = availableMoves[rand];
writeAndLog(`${player} move ${move.id}`)
}
function onBattleWin(winnerName) {
console.log(`対戦結果: ${winnerName}の勝ち`)
winCount[winnerName]++
battleCount++
}
const streamOutputHandler = async () => {
for await (const output of stream) { // cf. async iterables
console.log(`<< ${output}`)
const lines = output.split('\n')
if (lines[0] === 'end') {
if (battleCount < NUM_OF_BATTLES) {
startNewBattle();
} else {
console.log('すべての対戦が終了しました。');
console.log('勝利数:')
console.log(winCount);
}
continue;
}
lines.forEach(line => {
const records = line.split('|')
if (records.length < 2) {
return;
}
switch (records[1]) {
case 'request':
const request = JSON.parse(records[2])
onChoiceRequest(request)
break;
case 'win':
onBattleWin(records[2])
break;
default:
return;
}
});
}
};
streamOutputHandler();
startNewBattle();
次回につづくかもしれない