LoginSignup
19
17

More than 5 years have passed since last update.

【Ruby】リバーシのプログラムを書いたりするためのgemをつくった

Last updated at Posted at 2015-03-04

概要

誰もが知っているボードゲームのリバーシで遊ぶためのgemをつくりました。

自分で対戦して遊んだり、プログラム同士で試合をさせたり、自分でプレイヤーのアルゴリズムを書いてみたりすることができます。

ソースコード

インストール

gem install reversi

デモ

reversi.rb
require 'reversi'

Reversi.configure do |config|
  config.disk_color_b = 'cyan'
  config.disk_b = 'O'
  config.disk_w = 'O'
  config.progress = true
end

game = Reversi::Game.new

game.start
puts "black #{game.board.status[:black].size}"
puts "white #{game.board.status[:white].size}"

これを実行すると...

00020.png

対戦が開始されます。
ソースコードへのリンク先のREADMEで実際に動いてる様子を見ることができます。

実行前の設定

Reversi.configure にデモの例の様にブロックを渡すことで各種設定を行います。
変更が無い限り、それ以降の処理全てに反映されます。

ちなみにゲームで使う石はここではディスクと呼ぶことにしました。pieceとかstoneとかdiscとか呼び方はいろいろあるようです。

名前 説明 初期値
player_b 先手のプレイヤーが使うアルゴリズムを実装したクラスオブジェクト Reversi::Player::RandomAI
player_w 後手のプレイヤーが使うアルゴリズムを実装したクラスオブジェクト Reversi::Player::RandomAI
disk_b 先手が使うディスクの文字列 半角1文字 'b'
disk_w 後手が使うディスクの文字列 半角1文字 'w'
disk_color_b 先手が使うディスクの色の文字列かシンボル 色指定なし
disk_color_w 後手が使うディスクの色の文字列かシンボル 色指定なし
progress 途中経過表示モードのon/off false
stack_limit 連続で「戻る( Reversi::Board#undo! )」が使用できる上限回数 3

ディスクの文字列や色は Reversi::Board#to_s で返される文字列に反映されます。
ディスクの色は black, red, green, yellow, blue, magenda, cyan, white, gray から選べます。

途中経過表示モードをoffにすると何も出力されません。

自分で手を入力する

いわゆる人間がコンピュータと対戦するモードです。
どちらかのプレイヤーに Reversi::Player::Human をセットします。両方にセットすることもできますが、両方とも自分で入力することになります。

自分のつくったプログラムと対戦して、どんな感じになってるか確認したりするのにも使えます。

これを実行すると現在の状態が表示され入力待ちになるので d3 のように位置を入力していきます。ゲームが終了すればそのまま終わりますが、途中で終了したい場合は qexit と入力します。

Reversi.configure do |config|
  config.player_b = Reversi::Player::Human
end

game = Reversi::Game.new
game.start

思考ルーチンを自分で書いてみる

Reversi::Player::BasePlayer を継承して moveメソッドを定義することで、オリジナルのプレイヤークラスをつくることができます。

スーパークラスのmoveを呼び出す必要はありませんが、initializeメソッドを用意する場合は必ず呼び出してください。Reversi::Game#start でそれぞれの手を逐一チェックしているので、不正な手(相手のディスクをひっくり返せない手など)を打つようなプレイヤーを戦わせようとしても、途中でエラーが発生します。

なお、長くなりそうなのでここではアルゴリズムの説明はしません。Negamax法の例だけ書いてみたので興味のある人は調べてみてください。

自作プレイヤー作成方法

  1. Reversi::Player::BasePlayer#next_moves で現在打てる手の全ての位置を配列として得る。
  2. その中からなんらかの方法で手をひとつ選ぶ。
  3. Reversi::Player::BasePlayer#put_disk の引数にその手を渡す。

以上が大まかな流れです。 next_moves を使用した際、 得られる配列が空である場合は打てる場所が無いので何もしないでください。

また、手を選ぶ段階において探索として局面を進めてみたい場合があるかと思います。 next_movesput_disk はそれぞれ第一引数、第三引数にtrueとfalseのどちらを与えるかによって、自分と相手どちらの操作を行うかを選ぶことができます。(どちらもデフォルトはtrueで自分に対する操作になります) 移動した局面において自分や相手のディスクがどこにあるかという情報は Reversi::Player::BasePlayer#status で得ることができます。

最後に局面の評価が終了したら、前の局面に戻る必要があります。 そこで、 move メソッドに引数として与えられたReversi::Board クラスのオブジェクトである board に対して undo! メソッドを使用します。このメソッドは直前の put_disk で行った変更を取り消します。

簡単に言うとゲーム木をイメージした際、 現在の節点から伸びている枝が next_moves で、そのうちのひとつを選び次の節点(局面)に移動するのが put_disk、現在の節点からひとつ上の節点へ戻るのが undo! です。

ランダムな手を打つ例

現在打てる手の中からランダムに一手を選ぶ例で、moveメソッド内でしなければならないことを守った上での必要最低限のものになっています。 Reversi::Player::RandomAI が同様の内容となっています。

class MyAI < Reversi::Player::BasePlayer
  def move(board)
    moves = next_moves
    put_disk(*moves.sample) unless moves.empty?
  end
end

Reversi.configure do |config|
  config.player_b = MyAI
  config.progress = true  
end

game = Reversi::Game.new
game.start

NegaMax法の例

MinMax法の亜種みたいなやつです。デフォルトの設定では3つ前の状態までしか連続で戻れないので、以下の例では3手先読みにしていますが、それより先まで読む場合は最初の設定で stack_limit に何手前まで保持するかの数字をセットしてください。局面評価では単純に各位置に重みを与え、自分のディスクがある位置の合計値をみています。 Reversi::Player::NegaMaxAI が同様の内容になっています。

class MyAI < Reversi::Player::BasePlayer

  def initialize(_color, _board)
    super

    # 盤の位置による重み付け
    point = [
      100, -10,  0, -1, -1,  0, -10, 100,
      -10, -30, -5, -5, -5, -5, -30, -10,
        0,  -5,  0, -1, -1,  0,  -5,   0,
       -1,  -5, -1, -1, -1, -1,  -5,  -1,
       -1,  -5, -1, -1, -1, -1,  -5,  -1,
        0,  -5,  0, -1, -1,  0,  -5,   0,
      -10, -30, -5, -5, -5, -5, -30, -10,
      100, -10,  0, -1, -1,  0, -10, 100
    ]
    @evaluation_value = 
      Hash[(1..8).map{ |x| (1..8).map{ |y| [[x, y], point.shift] } }.flatten(1) ]
  end

  def move(board)
    moves = next_moves
    return if moves.empty?
    next_move = moves.map do |move|
      { :move => move, :point => evaluate(move, board, 1, true) }
    end
    .max_by{ |v| v[:point] }[:move]
    put_disk(*next_move)
  end

  def evaluate(move, board, depth, color)
    # 次の局面へ移動
    put_disk(*move, color)
    # そこから伸びる枝
    moves = next_moves(!color)
    if depth == 3
      # 3手先まできたら自分のディスクがある位置の点数によって局面を評価
      status[:mine].inject(0){ |sum, xy| sum + @evaluation_value[xy] }
    elsif moves.empty?
      -100
    else
      # 子ノードの内最大の評価値であるものを選び符号を反転して現ノードの評価値とする
      -( moves.map{ |move| evaluate(move, board, depth + 1, !color) }.max )
    end
  ensure
    # 前の局面へ戻る
    board.undo!
  end
end

Reversi.configure do |config|
  config.player_b = MyAI
  config.progress = true
end

game = Reversi::Game.new
game.start

この他にも MinMax法の例などが Reversi::Player 以下にあります。これらの例も単純に位置の重み付けだけで局面評価を行っていますが、それ以外にも、着手可能数や開放度理論による評価など様々な工夫ができると思います。

おわりに

今回の反省点としては、自分の力不足であまりRubyっぽく書けなかったということと、実行に致命的に時間がかかるということです。3手先読みするプレイヤーを対戦させるだけで1試合が終わるのに分単位で時間がかかります。

C拡張で書き直して改善する予定ですが、現状では何千何万回繰り返し戦わせるみたいなことには向いてないです。

別言語でリバーシを実装しようと思ってるけど書いてみたアルゴリズムが正しいか試してみたい方、思考ルーチンを書く練習をしてみたい方などはぜひ触ってみてください。

メソッド名や全体の構成についてご意見ご感想ご指摘ごマサカリ等お待ちしています。

追記(2015/03/10)

v1.0.0 以降の変更点

C拡張で書き直したのでかなり高速になりました。それに伴い、今まで [:a, 3] のような形で得ていた盤の座標を表す配列は [1, 3] のように変更されました。本稿の該当箇所は修正済みです。

19
17
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
17