Game of Life

2120 live cells · running

Code

Life module · lib/phx_demo/examples/life.ex
defmodule PhxDemo.Examples.Life do
  @cols 120
  @rows 80
  @cell 8

  def width, do: @cols * @cell
  def height, do: @rows * @cell
  def cell, do: @cell

  def init(density \\ 0.22) do
    alive =
      for y <- 0..(@rows - 1),
          x <- 0..(@cols - 1),
          :rand.uniform() < density,
          into: MapSet.new() do
        {x, y}
      end

    %{alive: alive}
  end

  def tick(%{alive: alive} = state) do
    counts =
      Enum.reduce(alive, %{}, fn {x, y}, acc ->
        Enum.reduce(-1..1, acc, fn dx, acc2 ->
          Enum.reduce(-1..1, acc2, fn dy, acc3 ->
            if dx == 0 and dy == 0 do
              acc3
            else
              nx = x + dx
              ny = y + dy

              if nx >= 0 and nx < @cols and ny >= 0 and ny < @rows do
                Map.update(acc3, {nx, ny}, 1, &(&1 + 1))
              else
                acc3
              end
            end
          end)
        end)
      end)

    next_alive =
      Enum.reduce(counts, MapSet.new(), fn {cell, n}, acc ->
        alive_now = MapSet.member?(alive, cell)

        if (alive_now and (n == 2 or n == 3)) or (!alive_now and n == 3) do
          MapSet.put(acc, cell)
        else
          acc
        end
      end)

    %{state | alive: next_alive}
  end

  def toggle(%{alive: alive} = state, x, y) do
    if x >= 0 and x < @cols and y >= 0 and y < @rows do
      alive =
        if MapSet.member?(alive, {x, y}),
          do: MapSet.delete(alive, {x, y}),
          else: MapSet.put(alive, {x, y})

      %{state | alive: alive}
    else
      state
    end
  end

  def render_background do
    Easel.new(width(), height())
    |> Easel.set_fill_style("#0b1020")
    |> Easel.fill_rect(0, 0, width(), height())
    |> Easel.render()
  end

  def render(%{alive: alive}) do
    instances = Enum.map(alive, fn {x, y} -> %{x: x * @cell, y: y * @cell} end)

    Easel.new(width(), height())
    |> Easel.template(:cell, fn c ->
      c
      |> Easel.set_fill_style("#7dd3fc")
      |> Easel.fill_rect(0, 0, @cell - 1, @cell - 1)
    end)
    |> Easel.instances(:cell, instances)
    |> Easel.render()
  end
end
Life LiveView · lib/phx_demo_web/live/life_live.ex
defmodule PhxDemoWeb.LifeLive do
  use PhxDemoWeb, :live_view

  @interval 80

  def mount(_params, _session, socket) do
    life = PhxDemo.Examples.life_init() |> Map.put(:running, true)

    socket =
      socket
      |> assign(:life, life)
      |> assign(:canvas, PhxDemo.Examples.life_render(life))
      |> assign(:background, PhxDemo.Examples.life_render_background())
      |> assign(:cell, PhxDemo.Examples.life_cell())
      |> Easel.LiveView.animate(
        "fg",
        :life,
        fn life ->
          next = if life.running, do: PhxDemo.Examples.life_tick(life), else: life
          {PhxDemo.Examples.life_render(next), next}
        end,
        interval: @interval,
        canvas_assign: :canvas
      )

    {:ok, socket}
  end

  def handle_info({:easel_tick, id}, socket) do
    {:noreply, Easel.LiveView.tick(socket, id)}
  end

  def handle_event("fg:click", %{"x" => x, "y" => y}, socket) do
    cx = div(round(x), socket.assigns.cell)
    cy = div(round(y), socket.assigns.cell)
    life = PhxDemo.Examples.life_toggle(socket.assigns.life, cx, cy)

    {:noreply,
     socket
     |> assign(:life, life)
     |> assign(:canvas, PhxDemo.Examples.life_render(life))}
  end

  def handle_event("toggle", _, socket) do
    {:noreply, update(socket, :life, &Map.update!(&1, :running, fn r -> !r end))}
  end

  def handle_event("randomize", _, socket) do
    running = socket.assigns.life.running
    life = PhxDemo.Examples.life_init() |> Map.put(:running, running)

    {:noreply,
     socket |> assign(:life, life) |> assign(:canvas, PhxDemo.Examples.life_render(life))}
  end

  def handle_event("clear", _, socket) do
    life = %{socket.assigns.life | alive: MapSet.new()}

    {:noreply,
     socket |> assign(:life, life) |> assign(:canvas, PhxDemo.Examples.life_render(life))}
  end

  def render(assigns) do
    ~H"""
    <.demo title="Game of Life" code_id="life">
      <div class="flex gap-2 mb-3">
        <button phx-click="toggle" class="px-3 py-1 border rounded text-sm">
          {if @life.running, do: "Pause", else: "Run"}
        </button>
        <button phx-click="randomize" class="px-3 py-1 border rounded text-sm">Randomize</button>
        <button phx-click="clear" class="px-3 py-1 border rounded text-sm">Clear</button>
      </div>

      <Easel.LiveView.canvas_stack id="life" width={@background.width} height={@background.height}>
        <:layer id="bg" ops={@background.ops} />
        <:layer id="fg" ops={@canvas.ops} templates={@canvas.templates} on_click />
      </Easel.LiveView.canvas_stack>

      <p class="text-sm text-gray-500 mt-2">
        {MapSet.size(@life.alive)} live cells · {if @life.running, do: "running", else: "paused"}
      </p>
    </.demo>
    """
  end
end