Phoenix LiveViewでライフゲーム
Elixirを書く練習も兼ねてLiveViewでライフゲームを動かすアプリを作ってみました。
ライフゲームについては以下をご参照ください。
作ったもの
nextで1世代ずつ進むことが出来ます。
マスをクリックすることで生死の状態を変えることが出来ます。
ソースコード
環境
$ 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に以下を追加します。
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
構造体で座標と生死の状態をもちます。
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の中の生きているセルの数をもちます。
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
盤面を操作する関数をここに書きます。
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{}をもちます。
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つの関数が必要です。
全体のコードです。
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