3
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?

LiveView(Elixir)とThree.jsでゲーミングライフゲーム

Posted at

はじめに

2026年、明けまして初投稿です。

古来より伝わる「ライフゲーム」を色々なテックで実装するチャレンジ、今年もやっていきます。

今回はElixirのフレームワークPhoenixのLiveViewを用いて、Three.js上でライフゲームをゲーミングPCよろしくピカピカに光って溢れかえるほど素敵に見える実装をしていきます。

過去シリーズは以下です。

ライフゲームとは

概要は先駆者様の解説に委ねます。

コーディングに必要なルールは以下となります。

  • 2次元グリッド上で展開され、各セルは「生」または「死」の2つの状態を持つ。
  • 各世代で、「隣接(上下左右と斜め4方向の計8方向にある)」セルを以下のルールに従って状態が更新される。
    • 誕生:死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代で誕生する。
    • 生存:生きているセルに隣接する生きたセルが2つまたは3つならば、次の世代でも生存する。
    • 過疎:生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
    • 過密::生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。
  • 端は常に死んでいるとみなす(固定境界)

簡単な設計説明

  • ライフゲームロジックはすべてElixir側で実装
  • それ以外はJavaScript側で実装

開発環境

  • MacStudio2023(M2 Max)
  • macOS Tahoe(26.1)
  • Erlang/OTP 27
  • Elixir 1.19.3
  • Three.js 0.182.0

プロジェクト準備

任意のフォルダ内にPhoenixプロジェクトを新規作成

mix phx.new $PROJECT_NAME --no-ecto

動作確認

# 起動
cd $PROJECT_NAME
mix phx.server

プロジェクにThree.jsを追加

cd assets
npm install three

assets/package.jsonに以下記述を確認

{
  "dependencies": {
    "three": "^0.182.0"
  }
}

Three.jsとの紐付け

Three.jsへのアクセスのための紐付け(Hook)を登録

  • assets/js/app.js
+ import ThreeScene from "./hooks/three_scene"
+ let threeHooks = { ThreeScene: ThreeScene }

const liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
- hooks: { ...colocatedHooks },
+ hooks: { ...colocatedHooks, ...threeHooks },
})

HTMLテンプレートへThree.jsの描画領域を設定

  • lib/appname/live/scene_live.ex
  def render(assigns) do
    ~H"""
    <div class="relative w-screen h-screen overflow-hidden bg-black">
      <%!-- FPS表示要素 --%>
      <label class="absolute top-4 left-4 z-10 text-white font-mono bg-black/50 px-2 py-1 rounded pointer-events-none">
        FPS: {@fps}
      </label>
      <%!-- 画面全体をThree.jsシーンに設定 --%>
      <div
        id="three-scene-container"
        phx-hook="ThreeScene"
        phx-update="ignore"
        class="absolute inset-0"
      >
      </div>
    </div>
    """
  end

ライフゲームロジック実装

ロジックは過去実装とほぼ同じ。

JavaScriptとのやりとりの実装を以下に記述。

Elixir側

初期セル設定

ページを開いた最初に実行される。

  @spec mount(any(), any(), map()) :: {:ok, map()}
  def mount(_params, _session, socket) do
    # 初期配置
    cells = init_cells()
    # 初期値登録
    socket =
      socket
      |> assign(cells: cells) # socket.assignsに連想配列のcellsキーと値が保持される
      |> assign(fps: 0) # socket.assignsに連想配列のfpsキーの要素を確保
      |> send_cell_count(get_width(), get_height())
      |> send_cell_alive_map(cells) # ヘルパー(後述)

    # ワーキングプロセス開始
    Process.send_after(self(), :main_loop, 1000)

    # 返す
    {:ok, socket}
  end

ワーキングプロセス

遅延実行を無限ループするよう仕掛ける。

  @spec handle_info(:main_loop, map()) :: {:noreply, map()}
  def handle_info(:main_loop, socket) do
    Process.send_after(self(), :main_loop, 250)
    {:noreply, main_loop(socket)}
  end

メインループ

ワーキングプロセスから呼び出される。

  @spec main_loop(map()) :: map()
  def main_loop(socket) do
    new_cells = next_generation_cells(socket.assigns.cells) # 保持されているcellsを引数に指定

    socket
    |> assign(cells: new_cells) # socket.assigns.cellsの値を更新
    |> send_cell_alive_map(new_cells)
  end

各種ヘルパー

セルの縦横個数を送信

  @spec send_cell_count(map(), integer(), integer()) :: map()
  def send_cell_count(socket, w, h) do
    push_event(socket, "sendCellCount", %{w: w, h: h})
  end

セルの生死状態マップを送信

  @spec send_cell_alive_map(map(), map()) :: map()
  def send_cell_alive_map(socket, cells) do
    js_cells =
      cells
      |> Enum.map(fn {{x, y}, v} ->
        {"#{x},#{y}", v} # キーを文字列に変換
      end)
      |> Enum.into(%{}) # Map型に整える

    push_event(socket, "sendCellAliveMap", %{cells: js_cells})
  end

JavaScriptからFPS値を受け取る

  # FPS表示更新
  @spec handle_event(<<_::72>>, map(), map()) :: {:noreply, map()}
  def handle_event("updateFps", %{"fps" => fps}, socket) do
    socket =
      socket
      |> assign(fps: fps)# socket.assigns.fpsの値を更新

    {:noreply, socket}
  end

Three.js制御

シーン制御については過去実装に基づいています。

Elixirとのやりとりの実装を以下に記述。

JavaScript側

各種イベントハンドラ

セルの縦横個数を取得

this.handleEvent("sendCellCount", (size) => {
  cellsWidth = size.w;
  cellsHeight = size.h;
  allCellObjectMap = createCubeMap(worldScene, cellsWidth, cellsHeight);
});

セルの生死状態マップを取得

this.handleEvent("sendCellAliveMap", (cells) => {
  aliveMap = cells.cells;
  // キューブのループ
  for (let key in allCellObjectMap) {
    if (allCellObjectMap.hasOwnProperty(key)) {
      // 生死状態
      let cell = allCellObjectMap[key];
      if (aliveMap.hasOwnProperty(key)) {
          // キーが一致するセルの生死状態を更新
          cell.alive = aliveMap[key];
      }
    }
  }
});

FPSを測定してElixir側に送信

const sendNowFps = () => {
  frames += 1;
  const time = performance.now();
  // 秒ごとに実行
  if (time >= prevTime + 1000) {
    const fpsFloat = (frames * 1000) / (time - prevTime);
    const fps = Math.round(fpsFloat * 100) / 100;
    // Elixir側にイベントを送信
    this.pushEvent("updateFps", { fps: fps });
    // リセット
    frames = 0;
    prevTime = time;
  }
}

プロジェクト全体

GitHub - LifeViewLifeGame01

実行結果例

Phoenixは動的プロジェクトのためGitHubPagesにはアップできないため、gifアニメを置いておきます。

  • 銀河パターン

galaxy03.gif

今後の展開予定

  • Elixir側でシーンのパラメータの設定等の役割を増やす
  • 他の技術でのチャレンジ

参考文献

乾燥した換装

実はWEB系フロントエンドツールはReact等も含めてほとんど触っていなかったので、プロジェクト構築にはだいぶ苦労しました。

この記事自体が備忘録になるので、今後もより深く構成を追求していきたいです。

おわりに

ここまでご閲覧いただきありがとうございます。

今後も思いつくままに記事投稿を続けて行きたい所存であります。

この世界は好都合に未完成、だから知りたいんだ。

3
0
0

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
3
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?