👾

Phoenix LiveViewでライフゲーム

2021/02/21に公開

Elixirを書く練習も兼ねてLiveViewでライフゲームを動かすアプリを作ってみました。

ライフゲームについては以下をご参照ください。
https://ja.wikipedia.org/wiki/ライフゲーム

作ったもの

nextで1世代ずつ進むことが出来ます。

マスをクリックすることで生死の状態を変えることが出来ます。

ソースコード
https://github.com/zui207/LiveView-game-of-life

環境

$ elixir -v
 Erlang/OTP 23 [erts-11.1.7] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe]
 
Elixir 1.11.2 (compiled with Erlang/OTP 23)
$ mix phx.new -v
Phoenix v1.5.3

プロジェクト作成

LiveViewを使いたいので --live オプションをつけます。
LiveView使用に必要な設定を勝手にしてくれます。

$ mix phx.new game_of_life --live

router.ex

lib/game_of_life_web/router.exに以下を追加します。

router.ex
defmodule GameOfLifeWeb.Router do
  use GameOfLifeWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {GameOfLifeWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", GameOfLifeWeb do
    pipe_through :browser

    live "/game", GameLive  # <--------------- 追加
    live "/", PageLive, :index
  end

  # Other scopes may use custom stacks.
  # scope "/api", GameOfLifeWeb do
  #   pipe_through :api
  # end

  # Enables LiveDashboard only for development
  #
  # If you want to use the LiveDashboard in production, you should put
  # it behind authentication and allow only admins to access it.
  # If your application does not have an admins-only section yet,
  # you can use Plug.BasicAuth to set up some basic authentication
  # as long as you are also using SSL (which you should anyway).
  if Mix.env() in [:dev, :test] do
    import Phoenix.LiveDashboard.Router

    scope "/" do
      pipe_through :browser
      live_dashboard "/dashboard", metrics: GameOfLifeWeb.Telemetry
    end
  end
end

liveマクロは、パスとLiveViewモジュールの名前を引数に取ります。(このあとliveディレクトリにgame_live.exというファイルを作成し、そのなかのGameLiveモジュールにLiveViewを記述していきます。)
ルーティングはこれで終わりです。

ライフゲームの実装

lib/game_of_life以下のモジュールにライフゲームのロジック部分を書きます。

lib/game_of_life

mapでセルの状態をもつことにしました。

モジュールは以下4つです。

  • cell.ex
  • cells.ex
  • board.ex
  • game.ex

cell.ex

構造体で座標と生死の状態をもちます。

cell.ex
defmodule GameOfLife.Cell do

  defstruct alive: false, position: {0, 0}

  def new(position, bool \\ false) do
    __struct__(position: position, alive: bool)
  end

end

cells.ex

cellはcell.exで定義した構造体です。
neiboursはタプルのリストで、そのタプルは周囲8マスのセルの座標です。
live_countにneiboursの中の生きているセルの数をもちます。

cells.ex
defmodule GameOfLife.Cells do

  defstruct cell: %{}, neibours: [], live_count: 0

  alias GameOfLife.Cell

  @offsets [{-1,-1},{0,-1},{1,-1},
            {-1,0},        {1,0},
            {-1,1}, {0,1}, {1,1}]

  def init(coord) do
    __struct__(cell: Cell.new(coord))
  end

  def neibours(%{cell: %{position: {x, y}}}=cells) do
    neibours =
      @offsets
      |> Enum.reduce([], fn {dx, dy}, acc ->
        cell = Cell.new({x+dx,y+dy})
        [cell | acc]
      end)

    %{cells | neibours: neibours}
  end

  def new(coord) do
    coord
    |> init()
    |> neibours()
  end

  def update(%{neibours: neibours}=cells, %{position: position}=cell) do
    updated =
      neibours
      |> Enum.map(fn %{position: n_position}=n_cell ->
        if n_position==position, do: cell, else: n_cell
      end)

    %{cells | neibours: updated}
  end

  def check(%{cell: cell}=cells) do
    bool = alive?(cells)
    %{cells | cell: %{cell | alive: bool}}
  end

  def status(%{cell: %{alive: alive}}), do: alive

  defp alive?(%{cell: %{alive: true}, live_count: count}) do
    case count do
      2 -> true
      3 -> true
      _ -> false
    end
  end
  defp alive?(%{cell: %{alive: false}, live_count: count}) do
    case count do
      3 -> true
      _ -> false
    end
  end
  
end

board.ex

盤面を操作する関数をここに書きます。

board.ex
defmodule GameOfLife.Board do

  @height 13
  @width  21

  alias GameOfLife.{Cell, Cells}

  def board(height, width) do
    for h <- 1..height, w <- 1..width, do: {w, h}
  end

  def new() do
    board(@height, @width)
    |> Enum.reduce(%{}, fn coord, acc ->
      Map.put(acc, coord, Cells.new(coord))
    end)
  end

  def set(board, points \\ [], bool \\ true) do
    points
    |> Enum.reduce(board, fn coord, acc ->
        new_cells = %{get_in(acc, [coord]) | cell: Cell.new(coord, bool)}
        %{acc | coord => new_cells}
       end)
    |> Enum.reduce(board, fn {coord, cells}, acc ->
        new_cells = get_in(count(board, cells), [coord])
        %{acc | coord => new_cells}
       end)
  end

  def count(board, %{cell: %{position: position}, neibours: neibours}=cells) do
    count =
      neibours
      |> Enum.reduce(0, fn %{position: coord}, acc ->
        bool =
          case get_in(board, [coord]) do
            nil   -> false
            cells -> get_in(cells, [Access.key(:cell), Access.key(:alive)])
          end

        if bool, do: acc+1, else: acc
      end)

    new_cells = %{cells | live_count: count}
    %{board | position => new_cells}
  end

  def check(board) do
    board
    |> Enum.reduce(%{}, fn {coord, cells}, acc ->
      Map.put(acc, coord, Cells.check(cells))
    end)
  end

  def update(board, cells) do
    board
    |> count(cells)
    |> check()
  end

  def status(board, point) do
    %{^point => cells} = board
    Cells.status(cells)
  end

  def alive?(board) do
    board
    |> Map.values()
    |> Enum.any?(&Cells.status/1)
  end

end

game.ex

構造体で世代と盤面を管理します。
boardはmapで、keyに座標のタプル、valueに%GameOfLife.Cells{}をもちます。

game.ex
defmodule GameOfLife.Game do

  defstruct generation: 0, board: %{}

  alias GameOfLife.Board

  def new() do
    __struct__(board: Board.new())
  end

  def set(%{board: board}=game, points, bool) do
    new_board = Board.set(board, points, bool)
    %{game | board: new_board}
  end

  def next(%{generation: generation, board: board}=game) do
    updated =
      board
      |> Enum.reduce(board, fn {coord, cells}, acc ->
        cells = get_in(Board.update(board, cells), [coord])
        %{acc | coord => cells}
      end)

    %{game | generation: generation+1, board: updated}
  end
  
end

LiveView

lib/game_of_life_web/live/game_live.exにゲームを描画するための処理を書きます。
LiveViewを使うためのファイルです。

game_live.ex

GameLiveモジュールにはmountとrenderの2つの関数が必要です。

全体のコードです。

game_live.ex
defmodule GameOfLifeWeb.GameLive do

  use GameOfLifeWeb, :live_view

  alias GameOfLife.{Board, Game, Pattern}

  def mount(_params, _session, socket) do
    if connected?(socket) do
      :timer.send_interval(500, :tick)
    end

    {:ok, new_game(socket)}
  end

  def render(assigns) do
    ~L"""
    <div>
      <svg width="840" height="520">
        <%= render_points(assigns) %>
      </svg>
      <button phx-click="start">start</button>
      <button phx-click="stop">stop</button>
      <button phx-click="next">next</button>
      <button phx-click="reset">reset</button>
      <button phx-click="galaxy">galaxy</button>
      generation: <%= inspect @game.generation %>
    </div>
    """
  end

  defp render_points(assigns) do
    ~L"""
    <%= for {{x, y}, %{cell: %{alive: alive}}} <- @game.board do %>
      <rect
        phx-click="point"
        phx-value-x="<%= x %>" phx-value-y="<%= y %>"
        width="40" height="40"
        x="<%= (x-1)*40 %>" y="<%= (y-1)*40 %>"
        style="
          stroke:rgb(0,0,0);
          <%= if alive do %>
            fill:rgb(0,0,0);
          <% else %>
            fill:rgb(255,255,255);
          <% end %>
        "
      />
    <% end %>
    """
  end

  def new_game(socket) do
    assign(socket, game: Game.new(), status: :stop, points: [])
  end

  def next(%{assigns: %{game: %{board: board}}}=socket) do
    if Board.alive?(board) do
      update(socket, :game, &Game.next/1)
    else
      assign(socket, status: :stop)
    end
  end

  def set(%{assigns: %{game: %{board: board}=game}}=socket, point) do
    status = Board.status(board, point)
    new_game = Game.set(game, [point], !status)
    assign(socket, game: new_game)
  end

  def handle_event("start", _, %{assigns: %{status: :ok}}=socket) do
    {:noreply, socket}
  end
  def handle_event("start", _, socket) do
    {:noreply, assign(socket, status: :ok)}
  end

  def handle_event("stop", _, %{assigns: %{status: :stop}}=socket) do
    {:noreply, socket}
  end
  def handle_event("stop", _, socket) do
    {:noreply, assign(socket, status: :stop)}
  end

  def handle_event("next", _, socket) do
    {:noreply, next(socket)}
  end

  def handle_event("reset", _, socket) do
    {:noreply, new_game(socket)}
  end

  def handle_event("galaxy", _, %{assigns: %{game: game}}=socket) do
    pattern = Pattern.galaxy()
    game = Game.set(game, pattern, true)
    {:noreply, assign(socket, game: game)}
  end

  def handle_event("point", %{"x" => x, "y" => y}, socket) do
    {:noreply, set(socket, {String.to_integer(x), String.to_integer(y)})}
  end

  def handle_info(:tick, %{assigns: %{status: :ok}}=socket) do
    {:noreply, next(socket)}
  end

  def handle_info(_, socket) do
    {:noreply, socket}
  end

end

クリックしたマスの座標を取得

phx-value-* attributeでほかのイベントと一緒に好きな値を送ることができます。

defp render_points(assigns) do
  ~L"""
  <%= for {{x, y}, %{cell: %{alive: alive}}} <- @game.board do %>
    <rect
      phx-click="point"
      phx-value-x="<%= x %>" phx-value-y="<%= y %>"
      width="40" height="40"
      x="<%= (x-1)*40 %>" y="<%= (y-1)*40 %>"
      style="
        stroke:rgb(0,0,0);
        <%= if alive do %>
          fill:rgb(0,0,0);
        <% else %>
          fill:rgb(255,255,255);
        <% end %>
      "
    />
  <% end %>
  """
end

clickイベントと一緒に座標の情報を送ります。

phx-click="point"
phx-value-x="<%= x %>" phx-value-y="<%= y %>"

handle_eventで受け取り、処理します。

def handle_event("point", %{"x" => x, "y" => y}=_params, socket) do
    {:noreply, set(socket, {String.to_integer(x), String.to_integer(y)})}
end

LiveView

定期的にmessageを送り、

def mount(_params, _session, socket) do
    if connected?(socket) do
      :timer.send_interval(500, :tick)
    end
end

handle_infoでmessageを受け取ります。
第一引数がmessage(今は:tick)とマッチした時、非同期に処理をします。

def handle_info(:tick, %{assigns: %{status: :ok}}=socket) do
    {:noreply, next(socket)}
end

各所にあるassign,update関数でsocketの中のassignsが更新された時、自動的にrender関数が呼び出され、更新された内容がクライアント側へ送られるという仕組みです。

おわりに

ロジックの部分をよく考えずに書いてしまったので効率的ではない所が多々あると思います。それもあってグリッドを広げてセルの数を増やしすぎると動きがもっさりしてしまいます。セルの状態のもたせ方と処理の流れは見直したほうが良さそうです。せっかくなのでGenServerを使って書き直してみようかとも思っています。

素人なのでWebやプログラミングについてかなり理解が浅いですが、とりあえず動くものを作ることができて感動しています!LiveView楽しいですね。

説明間違いの指摘や、アドバイス等ありましたらコメントよろしくお願いします🙇‍♂️

Discussion