亀場でプログラミング:対戦型ロボット
このページでは、「亀ロボット」同士の対戦アルゴリズムについて考察する。
Javaで書かれた戦車の対戦シミュレーター robocodeは、 ゲーム感覚で遊びながら、Java言語やオブジェクト指向の考え方を習得できる、すぐれたソフトウェアだ。 ここで用いている亀場シミュレーター(教員のお手製)も、robocodeを随分と参考にさせていただいた。
1. 掃除ロボット+α = 戦闘型ロボット
掃除ロボットにとっての「ゴミ」を「敵」と読み替えれば、掃除ロボットとは、ある種の兵器であると解釈することもできる。 「便利」であったり「効果的」であったりするテクノロジーは、悲しい現実だけれども、例外なく、軍事技術としても「有効」だ。 そのことを心のどこか深いところで意識しつつ、以下では、動的な環境に適応し行動するロボットという意味での「対戦ロボット」 のデザインを考察しながら、プログラミングのさらなるスキルアップを目指したい。
掃除ロボットと対戦型ロボットとの決定的な違いは、ゴミ(ドーナッツ)は動かないが、ロボットは動きもすれば、向こうからこちらを 攻撃することもある点だ。
亀場関係のソフトウェアについてのさらに詳しい説明は 亀場サーバー操作マニュアル を参照のこと。
準備
使うソフトは、タートルグラフィックスやお掃除ロボットのときと、何も変わるところは無い。 亀場サーバーをパソコン上に起動しておいて、turtle.hをインクルードしたCプログラムを作成、コンパイル・実行するだけだ。
ただし、一人で対戦してもつまらない(必ずしもそうとは言えないが・・・)ので、亀場サーバーを「パブリックモード」に変更して、 他流試合も可能な状態にしておく。 デフォルトの状態で、亀場サーバーは自分のパソコン("localhost"、あるいはIPアドレス"127.0.0.1")からの接続のみを許可する 状態(プライベートモード)になっている。 マウスを右クリックして Toggle Private/Public Mode を選択すると、他のパソコンからの接続も受け付けるようになる(あるいは、亀場ウィンドウが選択された状態で @ キーを押してもよい。このとき、ウィンドウのタイトルが"Public Turtle Field @ ..."に変化するはずだ)。
対戦の方法
亀場の準備が整ったら、亀場との接続命令(CON();
)の、これまでは"localhost"と記述してあった箇所を、
対戦相手に応じて書き換える。
亀場のウィンドウのタイトルのところの@マーク以降が、そのサーバーのホスト名になっているので、例えばホスト名が abc.d.ac.jp であったら
CON("abc.d.ac.jp") ;
と書き換える(ホスト名はパソコン毎にすべて異なる。)
こうして、二人以上で同じ亀場サーバーに接続するようにして、「いちにのさん」で、それぞれのプログラム(a.out)を実行すれば、対戦が始まる。 それぞれの亀は、ランダムな場所に、ランダムな向きで登場する(Cプログラムで最初の位置や方向を指定することはできない)。
最初に持ち点100が与えられ、砲弾を発射する度に1点ずつスコアが減るが、亀に命中すると+10点を得ることができる。 逆に被弾すると30点を失い、持ち点が0を下回った時点で"I'm dead.. bye !!"というメッセージとともに、プログラムは終了する。 最後まで生き残った亀のスコアが、その人の持ち点となる(死んだ亀は持ち点0)。 1回きりの対戦ではフェアでないので、5回程度対戦して、その平均を取るのが良いかもしれない。
2.対戦型ロボットのデザイン
対戦型亀ロボットの基本的なデザインは、お掃除ロボットのそれと何ら変わるところはない。 以下にひな形となるプログラム例を示す。
#include <stdio.h> #include <stdlib.h> #include <math.h> #include "turtle.h" /* 亀場を操作するための関数などが定義されているファイルをインクルード */ int main() { int nt,res,cnt ; CON("localhost") ; /* localhost のところは、接続先のホスト名(アドレス)に適宜変更する */ BMODE() ; /* 亀場を対戦モードに切り替える */ NM("KAME") ; /* 誰のロボットか判るように、必ずニックネームをつける */ while (1) { RF(20.0) ; Q_FINDER(&res) ; /* 前方センサーを調べて */ if (res==0) { /* 前が空いていたら */ FD(2.0) ; /* 前進する */ } else if (res==1) { /* 相手が亀だったら */ FIRE() ; /* 砲撃してから */ for (cnt=0; cnt<30; cnt=cnt+1) RT(3) ; /* 90度右ターン */ } else { /* それ以外の障害物だったら */ for (cnt=0; cnt<50; cnt=cnt+1) RT(3) ; /* 50*3=150度ターン */ } } }
3.さらにロボットを強くするためのヒント
センサーの特性を活かす
前方センサー(FINDER)は正面方向にしか有効ではないが、RF(到達距離)で、到達距離を大きく設定することで、遠方の敵や物体を、その種類を区別して検出することができる。 また、到達距離を変えながら何回か計測することで、相手までの凡その距離も推定できるだろう。
一方、方位センサー(RADAR)を使うと、全方位に渡っていちばん近い敵との相対的な角度を瞬時に把握できるが、返される情報には±20°の誤差が含まれるので、 その方向に砲弾を発射したとしても、必ずしも命中するとは限らない。 ついでながら、計測誤差を減らすいちばん基本的な方法は、「何度も計測してその平均を取る」こと。 誤差がランダムで無相関な場合、$N$回計測して平均を取ると、誤差は$1/\sqrt{N}$に減らせる。
状況をみながら行動パターンを切り替える
これはかなり高度な技になるが、亀場にどれくらい敵がいるか(Q_NT()関数を使う)、現在の自分のスコア(Q_SCORE()関数を使う)、自分の居る場所や方向(Q_POS(), Q_DIR()関数を使う)など、状況に応じて作戦行動を変更する手も考えられるだろう。
サーバーとの通信量を減らす
亀にコマンド(FD()やQ_FINDER()など)を送ると、亀場に通信パケットが送信され、その結果があなたのプログラム返されてくる。
つまり、亀を操作したり、情報を問い合わせたりする度に、パケットのピンポンが発生することになる。
そして、こうした亀場との通信には、少なからぬ時間を要する。つまり、同じ前進命令FD(2);
を10回送るにしても、
前方を毎回確認しながら進むコード
for (cnt=0; cnt<10; cnt=cnt+1) { Q_FINDER(&res) ; if (res==0) FD(2); }
よりも、確認無しで単純にFD(2)
を10回繰り返すコード
for (cnt=0; cnt<10; cnt=cnt+1) { FD(2); }
のほうが、亀場の上で亀は速く前進する。
イベント処理についてのページ
初期状態では、イベント検出が「オン」に設定されている。
使わないイベントの処理を禁止しておく
砲弾に撃たれたり、物体に衝突したりする事象(イベント)が検出されると、亀場はそれを自動的に「通知」してくる仕組みになっている。 そのためにパケットのやりとりが発生するので、時間のロスを生む原因となり得る。 それが気になる場合は、不要なイベントの検出をあらかじめ禁止しておく手がある。 例えば、FINDERに検出されたというイベントとRADARに検出されたことを知るイベントのふたつについて、検出を停止させるには、
TTL_DISABLE_EVENT(EVENT_DETECTED_BY_FINDER|EVENT_DETECTED_BY_RADAR) ;
を呼び出せばよい("|"はビット演算のOR)。全てのイベント検知を止めるには
TTL_DISABLE_EVENT(0xffffffff) ;
とする。
砲弾の特性を意識する
亀が発射した砲弾は亀場の中に1発だけ。つまり、立て続けにFIRE()しても、亀場に砲弾が残っているうちは、次の砲弾は発射されない。 であるから、むやみにFIRE()を繰り返すようなコードは、サーバーとの通信量だけが増えて、効率が悪い。 一方で、近接の亀に対しては、着弾時間が短いので、砲弾を連射することが可能となる。
ドーナッツで栄養補給する
DONUT()関数を呼び出すと、亀場にドーナッツをひとつ出現させることができる(ただし、それぞれの亀ごとに最大10個までで、 ドーナッツの出現位置はランダム)。 ドーナッツに砲弾を至近距離で命中させると(低いリスクで。なにしろドーナッツは攻撃して来ないから)+10点が得られる。
機能を関数にまとめる
プログラムがだんだんと長く複雑になってくると、何をやっているのか、自分でさえもわからなくなりがちだ。
そんのときは、C言語の「関数」の機能を使って、プログラムをモジュール化すると良い。
例えば、「一番近い亀のほうに照準(向き)を合わせて砲撃する」という機能を
look_around_and_fire()
という関数として定義するには、
main()
関数の「外」に
.... main() { ..... look_around_and_fire() ; /* main()関数の中で呼び出す */ ..... } /* main()関数の記述はここで終わり */ /* ここから先がlook_around_and_fire() 関数の定義 */ look_around_and_fire() { float a ; do { Q_RADAR(&a) ; LT(a) ; } while (fabs(a)>3.0) ; /* fabs()は絶対値を表す */ FIRE() ; } /* 他の関数も追加可能 */
といった具合に関数を定義し、main()
関数の中で呼び出すのがスマート。
なお、方位センサー(RADAR)には誤差があるため、上の例のように発砲したとしても、あまり命中率は高くないはずだ。