Elixir
ElixirDay 8

Elixirでリバーシ

More than 3 years have passed since last update.

先日、ElixirでAcquireというボードゲームを作ったという発表動画リポジトリ)と、それに触発されて四目並べを作ったというブログ(1, 2, 3)を目にしました。アドベントカレンダーの季節なので、なにか自分もマネして何かつくってみようということでReversiをつくりました。Acquireと四目並べは、ボードのマス1つ1つをプロセスで表現していたのですが、そこはマネしませんでした。


遊び方

まずはクローンしてiex -S mixします。

iex> game = Reversi.GameSup.start_game

"80638759"

iex> Reversi.Game.display_board(game)
a b c d e f g h
1
2
3
4 ○×
5 ×○
6
7
8

iex> Reversi.Game.put(game, "c", "4", :black)
:ok

iex> Reversi.Game.display_board(game)
a b c d e f g h
1
2
3
4 ×××
5 ×○
6
7
8

ボードがとても見づらいですが、自分の環境だとこんな感じです。

スクリーンショット 2015-12-07 8.52.55.png

当初は白と黒の丸で表示していたのですが、エディタやターミナル環境によってどっちが黒かわからなくなるので、白は◯、黒は×で表示するようにしました。

イケてない部分や、もうちょっと考慮しないといけない部分がある(置けない場所に石が置けてしまう、置ける場所がない場合にターンがスキップしない、などなどいっぱい)のですが、とりあえず遊べる感じになったのでこの記事を書いています。コードはJoe-noh/reversiにあります。


実装


データ構造

データ構造というほどのものではないのですが説明しておくと、構造体はGameBoardの2つがあります。Gameは1対局を表現しており、Boardは盤面を表します。定義は以下のとおり。

defmodule Reversi.Game do

defstruct uuid: nil, board: Board.new, current_color: :black

# uuid: ゲームのID(後述)
# board: 盤面
# current_color: 次に指すプレイヤーの色

...

end

defmodule Reversi.Board do

defstruct map: %{}

# {col, row}をキー、置いてある石の色を値とするmap。
# col: 0..7
# row: 0..7
# color: :black | :white | :empty
#
# 例: %Reversi.Board{map: %{
# {3, 3} => :white, {7, 6} => :empty, {4, 0} => :empty,
# {2, 1} => :empty, {2, 2} => :empty, {6, 4} => :empty,
# {0, 0} => :empty, {2, 0} => :empty, {6, 3} => :empty,
# {5, 7} => :empty, {6, 1} => :empty, {0, 2} => :empty,
# {4, 5} => :empty, {7, 0} => :empty, {2, 7} => :empty,
# {5, 1} => :empty, {0, 7} => :empty, {7, 1} => :empty,
# {3, 1} => :empty, {5, 6} => :empty, {6, 2} => :empty,
# {1, 3} => :empty, {5, 4} => :empty, {7, 4} => :empty,
# {3, 5} => :empty, {7, 2} => :empty, {0, 3} => :empty,
# {1, 0} => :empty, {7, 5} => :empty, {7, 7} => :empty,
# {3, 4} => :black, {4, 7} => :empty, {1, 5} => :empty,
# {3, 0} => :empty, {3, 7} => :empty, {4, 1} => :empty,
# {5, 2} => :empty, {2, 4} => :empty, {1, 2} => :empty,
# {1, 4} => :empty, {6, 7} => :empty, {4, 2} => :empty,
# {3, 6} => :empty, {1, 6} => :empty, {2, 3} => :empty,
# {5, 3} => :empty, {1, 7} => :empty, {0, 6} => :empty,
# {2, ...} => :empty, {...} => :empty, ...}}

...
end


プロセスツリー

実装面で注意したこととしては、最終的にネット対戦のバックエンドとして利用できるようにすることです。そのためには、まず複数ゲームを同時に行えなければなりません。今回はReversi.GameGenServerモジュールとし、Reversi.GameSup:simple_one_for_oneでぶら下げる構成にしました。

defmodule Reversi.GameSup do

use Supervisor

...

def init(_) do
children = [
worker(Reversi.Game, [])
]
opts = [strategy: :simple_one_for_one]

supervise(children, opts)
end

...
end

3ゲーム起動したときにプロセスツリーが以下。<0.91.0>, <0.93.0>, <0.95.0>がそれぞれの対局です。

スクリーンショット 2015-12-03 21.25.13.png

それぞれのゲームの盤に石を置くためには各ゲームのPIDが必要になりますが、Webをインターフェースとしたときにその扱い方がよくわかりませんでした。例えば、石を置く操作をどこかのエンドポイントへのPUTにしたとすると、どうやってPIDを指定したらいいんだー!と思ったわけです。そこで、各ゲームのPIDに文字列のIDを振ることにしました。前述の「遊び方」に出てきた"80638759"がそれです。

各ゲームは、初期化の際にReversi.NameResolverというサーバプロセスに自分のIDを登録し、外からゲームへの操作をする際は1度NameResolverに問い合わせてPIDを得てからメッセージを送ります。

defmodule Reversi.Game do

use GenServer

...

def init(uuid: uuid) do
NameResolver.register(uuid, self)

{:ok, %__MODULE__{uuid: uuid}}
end

def put(uuid, col, row, color) do
NameResolver.whereis(uuid)
|> GenServer.call({:put, col, row, color})
end

...
end

文字列にしておけば、DB等への永続化も都合が良さそうです。


パターンマッチ

リバーシといえば、石を置いた後に、挟まれた石が裏返るかどうか判定する処理が必要になります。ここでパターンマッチをいい感じにキメることができました。まず、置いた石から盤の端までのリストを作ります。1つ石を置くと、盤の端でなければ8方向に対応するリストが作られます。

   a b c d e f g h

1
2
3
4 ×○
5 ○×
6
7
8

例えばここでC5に×を置くと、右方向に

#   C5      D5      E5      F5      G5      H5

[:black, :white, :black, :empty, :empty, :empty]

あとはこれを以下の関数にぶち込んでやれば、各石が裏返るかどうかが判定できます。

defp judge_flip(disks_line = [head | _]) when head in [:black, :white] do

do_judge_flip(disks_line)
end

defp do_judge_flip([a, b, b, b, b, b, b, a]) when not b in [a, :empty] do
[false, true, true, true, true, true, true, false]
end

defp do_judge_flip([a, b, b, b, b, b, a | rest]) when not b in [a, :empty] do
[false, true, true, true, true, true, false | falses(rest)]
end

defp do_judge_flip([a, b, b, b, b, a | rest]) when not b in [a, :empty] do
[false, true, true, true, true, false | falses(rest)]
end

defp do_judge_flip([a, b, b, b, a | rest]) when not b in [a, :empty] do
[false, true, true, true, false | falses(rest)]
end

defp do_judge_flip([a, b, b, a | rest]) when not b in [a, :empty] do
[false, true, true, false | falses(rest)]
end

defp do_judge_flip([a, b, a | rest]) when not b in [a, :empty] do
[false, true, false | falses(rest)]
end

defp do_judge_flip(disks) do
falses(disks)
end

defp falses(list) when is_list(list) do
length(list) |> falses
end

defp falses(length) do
List.duplicate(false, length)
end

a == :emptyであることはないはずなので、ガード式では判断していません。


まとめ

複数ゲームを同時に管理できるような構成でリバーシを実装しました。実際に狙い通りにWebのバックエンドとして動くかは試せていないですし、諸々の処理ももっと良い方法があると思いますのでバシバシ指摘ください。