モンテカルロ木探索で一人不完全情報ゲーム「計算」を賢くする[3]


2015年 09月 22日

今回から、実際にプログラムの開発を行っていこう。
プログラミング言語はスピードが出る言語でかつそこそこ高機能なら何でも構わないのだが、とりあえずJavaで書くことにした。

今回は、モンテカルロ木探索の有効性を確認するだけなので、ガチガチにコーディング規則に則って書くのはやめて、気楽に書く。動作確認だけなので、GUIは省略し、CUI、それもほとんど出力だけのものにする。

そうなると、プログラムもかなり短くなるはず。Javaなので、当然オブジェクト指向にはなるのだが、いっぱいクラスを作ってということは止めて、一人遊び「計算」のクラス Caliculation を1つだけ用意し、もしクラスにしたいものが発生した場合は、Caliculation内のサブクラスで済ます。

まず、全体で必要な定数を決めておく。

public class Caliculation {
  int        MAXNUM = 13;
  int        KUZUNUM = 4;
  int        DAINUM = 4;
  int        MAXCOUNT = MAXNUM * DAINUM;

大文字にして定数らしさを出しているが、実際に定数にしていないのは、「計算」だけでなく、「コンピュータ」や「超人」さらには、その他のバリエーションにも対応できるようにしたためだ。これらのバリエーションは、コマンド引数で指定することができ、それに従ってこれらの数字を変更してから走らせるためである。

まず、盤面を管理するためのデータを用意する。

lab3-1.png屑札の山はKUZUNUM 個存在し、長さは余裕をもって全カード枚数である MAXCOUNTとしている。
各屑札の山にあるカード枚数も記憶する。
屑札のトップだけが直接参照されるのだが、別に変数を用意することはしない。

手札として、全カード枚数をサイズとする配列 tefuda を用意している。
どんどんカードをめくると手札の枚数が減るが、現在まだ表になっていない手札の枚数をtefudalenとする。
手札のトップは特に重要なので、別の変数 tefudatop を用意する。
手札をめくる行為は、tefudaにある有効なtefudalen枚のカードから1枚を抜いて、tefudatopに移すことである。

台札は、どんどん重ねていくが、トップだけが意味あるので、daifudatopという配列を用意した。
そして、台札はトップ以上に、その上に重ねられる数が重要なので、daifudanextを用意した。
台札に次に重ねられるカードの数字は、台ごとに差が決まっていて、それをdaifudagapに用意した。この値はプログラム実行中に変化することはない。

// 屑札
int[][] kuzu         = new int[KUZUNUM][MAXCOUNT];
int[]     kuzulen     = new int[KUZUNUM];

// 手札
int[]    tefuda  = new int[MAXCOUNT];
int     tefudalen;
int     tefudatop;

// 台札
int[]    daifudatop  = { 0, 0, 0, 0 };   // 台札
int[]    daifudanext = { 1, 2, 3, 4 };   // 次における札
int[]    daifudagap  = { 1, 2, 3, 4 };   // 台札の前の数字とのギャップ(固定)

これだけの変数を操作することで、「計算」のカードの動きを全て表せる。
以降の説明では、これらの全カードの状態を「盤面」ということにする。

あと、いくつかの補助的な変数を用意する必要がある。

成功したかどうか、つまり全てのカードが台札に積まれたかどうかの判断が必要なので、まだ積まれていないカードの枚数を示す変数 restcount を用意した。

int     restcount = MAXCOUNT;

カードの動かし方にはいくつかの種類があり、それを区別するためにenumを使ってみた。
直感的に判りやすいように、4つの動きを漢字で表現してみた。Javaはこういうこともできるということで。

enum MoveType { 手札, 手札台札, 手札屑札, 屑札台札 };


「計算」では、ランダムな操作が頻繁に出てくるので、乱数を生成するためのRandomオブジェクトを1個用意した。
とりあえず、現時刻で初期化している。

Random rnd = new Random(System.currentTimeMillis());        // 乱数

このような処理では、乱数は非常に重要で、乱数が乱数になっていないと、良い結果が得られない。
そのため、乱数の使用には最新の注意が必要である。本来なら、メルセンヌ・ツイスタ乱数を使うべきであるが、わかりやすさを優先して(横着をして)標準のもので済ました。

これで、とりあえずデータは揃った。
次に行うべきは、カードの動きを表現することである。 MoveTypeを用意したのだが、それらの4種類の移動タイプがどういうものか、実体を作らないといけない。

移動クラスMove

class Move {
  MoveType    typ;        // 手札、手札台札, 手札屑札, 屑札台札
  int        fudanum;    // 札番号 1〜K
  int        dainum;     // 台札番号 0〜
  int        kuzunum;    // 屑札番号 0〜

移動が決まれば、現在の盤面は常にわかっているので、カードの番号は必然的に決まるのだが、とりあえず持っておく。しかし、実際に移動を伴わないタイプが「手札」の場合には、札番号だけが肝心な情報である。
これらを同じ Moveにまとめてしまったので、札番号が入っている。
タイプによっては、不要なデータ項目もあるが、これえらをまとめて、1つのオブジェクトで移動を示すことにした。

Moveオブジェクトの生成は2つ、カードの移動を伴う場合と、手札をめくる場合で別々に用意した。

Move( MoveType tp, int n, int kuzu, int dai ) {
  typ = tp;
  fudanum = n;
  dainum = dai;
  kuzunum = kuzu;
}

Move( int n ) {
  typ = MoveType.手札;
  fudanum = n;
}


次に、この Moveサブクラスの中に、実行メソッドを追加する。

void    exec() {
  switch( typ ) {
    case 手札:
    execTefuda(fudanum);
    break;
    case 手札台札:
    execTefudaDaifuda(dainum);
    break;
    case 手札屑札:
    execTefudaKuzufuda(kuzunum);
    break;
    case 屑札台札:
    execKuzufudaDaifuda(kuzunum,dainum);
  }
}

これでは、タイプ別に異なるメソッドを実行しているだけに過ぎないのだが、判りやすいからこれでよい。
メソッドexecから呼び出される4つのメソッドは、計算全体のクラス、Calculationのメソッドとして用意してある。

全部説明するのは面倒なので、屑札台札の場合だけを説明する。

void execKuzufudaDaifuda( int k, int d ) {
  int n =kuzu[k][--kuzulen[k]];
  movetodaifuda( n, d );
}

屑札の山kから、台札の山dへカードを移す。このメソッドは、必ず移動可能な場合だけ呼ばれるので、エラーチェックはしなくてよい。
まず、指定の屑札の一番上のカードを取り出し、そのカードnを台札の山dへ移すメソッドmovedaifudaを呼び出す。

メソッドmovetodaifudaは、屑札台札の場合だけではなく、手札台札の場合にも共通につかえるので、別メソッドになっている。

void    movetodaifuda( int n, int daito ) {
  daifudatop[daito] = n;
  --restcount;

  if( n == MAXNUM ) {
    daifudanext[daito] = 0;    // 不可能を設定
    return;
  }

daifudanext[daito] += daifudagap[daito];
if( daifudanext[daito] > MAXNUM )
daifudanext[daito] -= MAXNUM ;
}

台札にK(MAXNUM)を移動し終えたら、その台札の山は終了するので、次の台札候補を0にしている。
次の台札の計算を最後にやって終わっている。

こんな感じで、札の移動ができる。
そして、これらを組み合わせると、とりあえずでたらめだけれども、プレイすることだできるようになる。
次回は、今回説明したことを組み合わせて、ルールを守る以外でたらめなプレイをする「プレイアウト」なるものを説明する。