2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Phoenix LiveViewでReact TutorialのTicTacToeを実装してみる①

Last updated at Posted at 2022-04-10

発端

  1. チュートリアルの写経から一歩踏み出して練習したい
  2. いい感じのものが思いつかない
  3. せや、他のチュートリアルをパクろう

という感じです。
タイトルに①とあるのは、①では一旦愚直に実装して②でリファクタをしていくという感じで考えています。

やること

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をつける慣習があるみたいです。

lib/ticktacktoe_web/router.ex
  scope "/", TicktacktoeWeb do
    pipe_through :browser

+   live "/game", GameLive
    live "/", PageLive, :index
  end

HTML/CSS周り

一旦デフォルトの設定には消えてもらいます。
bodyの中身を<%= @inner_content %>のみにしただけです。

lib/ticktacktoe_web/templates/layout/root.html.eex
<!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_length
    • 3目ならべが3×3マスの設定
    • 勝利判定など3マス前提の箇所があるのであんまり意味ないけど一応

実装方針

簡単に概要だけ説明します

  • historiesのなかで最も大きいstepの盤面の状態のものを表示する
    • マスの選択状態
    • 次の手番 or 勝者
  • マスをクリックするとマスのリストのindexを渡す。"select"でhandle_eventする
    • 手番の状態やマスの選択状態をみて盤面、手番、勝者を更新する
  • move to ...ボタンをクリックするとstep数を渡す。"move"でhandle_eventする
    • historiesでループさせてボタンを生成する
    • 渡ってきたstep数以下のhistoryを消す(イミュータブルとは)
lib/ticktacktoe_web/live/game_live.ex renderメソッド以外
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
lib/ticktacktoe_web/live/game_live.ex のrenderメソッドだけ
# ...
  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に分けていきます

時期未定(実装はある程度完了してる)

2
0
1

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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?