発端
- チュートリアルの写経から一歩踏み出して練習したい
- いい感じのものが思いつかない
- せや、他のチュートリアルをパクろう
という感じです。
タイトルに①とあるのは、①では一旦愚直に実装して②でリファクタをしていくという感じで考えています。
やること
Reactのチュートリアルでは3目並べを実装します。
web感は弱めですが機能としてはミニマムかつ状態の管理周りを幅広く扱えるので個人的に好きです。
あと3目並べなのでゲームロジック自体が複雑じゃないのも嬉しいです。
今回はこの3目並べとほぼ同じようなものをLiveViewで実装していきます
$ elixir -v
Erlang/OTP 23 [erts-11.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe] [dtrace]
Elixir 1.11.4 (compiled with Erlang/OTP 23)
$ mix phx.new -v
Phoenix v1.5.10
実装
プロジェクト作成
mix phx.new ticktacktoe --live
LiveView使うので--live
オプションを使います。
LiveView出たときは設定周りゴリゴリ書いてかなり大変だった気がしたのですが、これでほぼ動くのでありがたいです。
tick-tack-toeの正式表記がわからなかったので適当に1単語にしてます。
router
今回はgame
で設定します。
末尾にLiveをつける慣習があるみたいです。
scope "/", TicktacktoeWeb do
pipe_through :browser
+ live "/game", GameLive
live "/", PageLive, :index
end
HTML/CSS周り
一旦デフォルトの設定には消えてもらいます。
bodyの中身を<%= @inner_content %>
のみにしただけです。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Ticktacktoe", suffix: " · Phoenix Framework" %>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<%= @inner_content %>
</body>
</html>
cssは↓をassets/css/app.css
にコピペ
https://codepen.io/gaearon/pen/gWWZgR
基本はReactチュートリアルのものを丸パクリします。
三目並べ
さっそく実装です。
この記事の時点では状態管理や依存などは一旦考えず愚直に実装しています。
テンプレートの部分とelixirの部分がいい感じにハイライトする設定ができなかったのでrenderメソッドだけ切り出しています(^q^)
assigns
で管理するもの
- histories
- マスを選んだ履歴、Reactのチュートリアルでも特定の状態に戻せるような実装になっていた
- Elixirぽく書いたので最新のリストは末尾ではなく先頭に追加されていきます
- historyの中身
- board
- 盤面の情報
- この時点では、
nil
,"X"
,"O"
のどれかが入る要素数が9のリスト
- is_next_x
- 次の手番がXかどうかのbooleanの値
- (この時点ではhistories配下にいますが後々boardの配下に移動します)
- (個人的にis_hogehogeみたいな変数名は好きではない)
- winner
- 勝者
nil
の場合はまだ決まっていない -
nil
,"X"
,"O"
のどれかが入る - (こちらも後々boardの配下に移動します)
- 勝者
- step
- historyのstep数
- 1の場合初期状態
- board
- board_length
- 3目ならべが3×3マスの設定
- 勝利判定など3マス前提の箇所があるのであんまり意味ないけど一応
実装方針
簡単に概要だけ説明します
- historiesのなかで最も大きいstepの盤面の状態のものを表示する
- マスの選択状態
- 次の手番 or 勝者
- マスをクリックするとマスのリストのindexを渡す。"select"でhandle_eventする
- 手番の状態やマスの選択状態をみて盤面、手番、勝者を更新する
-
move to ...
ボタンをクリックするとstep数を渡す。"move"でhandle_eventする- historiesでループさせてボタンを生成する
- 渡ってきたstep数以下のhistoryを消す(イミュータブルとは)
defmodule TicktacktoeWeb.GameLive do
use TicktacktoeWeb, :live_view
@board_length 3
def mount(_params, _session, socket) do
{:ok, reset_game(socket)}
end
def handle_event("select", %{"point" => point}, %{assigns: %{histories: histories}} = socket) do
formatted_point = String.to_integer(point)
%{board: board, is_next_x: is_next_x, winner: winner, step: step} = Enum.max_by(histories, &(&1[:step]))
cond do
winner == nil and Enum.at(board, formatted_point) == nil ->
%{is_next_x: next_is_next_x, board: next_board} = select_point(board, is_next_x, formatted_point)
next_histories = [%{board: next_board, is_next_x: next_is_next_x, winner: calc_winner(next_board), step: step + 1} | histories]
{:noreply, assign(socket, histories: next_histories)}
true ->
{:noreply, socket}
end
end
def handle_event("move", %{"step" => str_step}, %{assigns: %{histories: histories}} = socket) do
target_step = String.to_integer(str_step)
{:noreply, assign(socket, histories: Enum.filter(histories, fn %{step: step} = history -> step <= target_step end))}
end
defp reset_game(socket) do
assign(socket, histories: [new_game()], board_length: @board_length)
end
defp new_game do
%{board: empty_board(@board_length), is_next_x: true, winner: nil, step: 1}
end
defp empty_board(length) do
List.duplicate(nil, length * length)
end
defp select_point(board, is_next_x, point) do
%{is_next_x: !is_next_x, board: List.replace_at(board, point, (if is_next_x, do: "X", else: "O"))}
end
defp calc_winner(board) do
lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]
lines
|> Enum.map(fn [a, b, c] ->
if Enum.at(board, a) != nil && Enum.at(board, a) == Enum.at(board, b) && Enum.at(board, a) == Enum.at(board, c) do
Enum.at(board, a)
else
nil
end
end)
|> Enum.filter(&(&1 != nil))
|> List.first
end
end
# ...
def render(assigns) do
~L"""
<div class="game">
<% %{board: board, is_next_x: is_next_x, winner: winner} = Enum.max_by(@histories, &(&1[:step])) %>
<div class="game-board">
<%= for x <- 0..(@board_length - 1) do %>
<div class="board-row">
<%= for y <- 0..(@board_length - 1) do %>
<% point = x * @board_length + y %>
<button class="square" phx-click="select" phx-value-point="<%= point %>">
<%= Enum.at(board, point) %>
</button>
<% end %>
</div>
<% end %>
</div>
<div class="game-info">
<div class="status">
<%= if winner == nil do %>
Next player: <%= if is_next_x, do: "X", else: "O" %>
<% else %>
Winner: <%= winner %>
<% end %>
</div>
<ol>
<%= for %{step: step} <- Enum.reverse(@histories) do %>
<% desc = if step == 1, do: "Go to game start", else: "Go to move ##{step - 1}" %>
<li>
<button phx-click="move" phx-value-step="<%= step %>"><%= desc %></button>
</li>
<% end %>
</ol>
</div>
</div>
"""
end
# ...
実装結果
Reactチュートリアルと同じような挙動なので省略します。
感想戦
-
calc_winner/1
メソッドをもっといい感じに書きたい- 全部の勝利パターンを見てるはやめたい VS EnumやListで完結させたい
- パターンマッチ気持ちが良い
- 書き方間違えてるのに気づかなくて1時間くらいドハマリしたけど
- イベントのハンドル周りの書き方
- オブジェクト側でやりたいけど、LiveViewだとどう書くのが良いか難しいなと思った
- 今回の例だとマス側のモジュールでハンドルしたいけど、LiveViewのことを知ってるモジュールにしたくない
- SSR(?)ええやん VS サーバーコストとかどうなんだろ感
- オンライン対戦とかの拡張はすぐできそうなので割と夢が膨らむ
次回!
愚直実装から依存とかを考えながらmoduleに分けていきます
時期未定(実装はある程度完了してる)