先日、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
ボードがとても見づらいですが、自分の環境だとこんな感じです。
当初は白と黒の丸で表示していたのですが、エディタやターミナル環境によってどっちが黒かわからなくなるので、白は◯、黒は×で表示するようにしました。
イケてない部分や、もうちょっと考慮しないといけない部分がある(置けない場所に石が置けてしまう、置ける場所がない場合にターンがスキップしない、などなどいっぱい)のですが、とりあえず遊べる感じになったのでこの記事を書いています。コードはJoe-noh/reversiにあります。
実装
データ構造
データ構造というほどのものではないのですが説明しておくと、構造体はGame
とBoard
の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.Game
をGenServer
モジュールとし、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>
がそれぞれの対局です。
それぞれのゲームの盤に石を置くためには各ゲームの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のバックエンドとして動くかは試せていないですし、諸々の処理ももっと良い方法があると思いますのでバシバシ指摘ください。