はじめに
2026年、明けまして初投稿です。
古来より伝わる「ライフゲーム」を色々なテックで実装するチャレンジ、今年もやっていきます。
今回はElixirのフレームワークPhoenixのLiveViewを用いて、Three.js上でライフゲームをゲーミングPCよろしくピカピカに光って溢れかえるほど素敵に見える実装をしていきます。
過去シリーズは以下です。
- ライフゲームをモダンな書き方で・Swift編・探究の章
- ライフゲームをモダンな書き方で・Swift編・発動の章
- ライフゲームをモダンな書き方で・Rust接触編
- ライフゲームをモダンな書き方で・Elixir接触編
- ライフゲームをElixir言語で並列化したかっただけなのに…
- Three.jsでゲーミングライフゲーム
ライフゲームとは
概要は先駆者様の解説に委ねます。
コーディングに必要なルールは以下となります。
- 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;
}
}
プロジェクト全体
実行結果例
Phoenixは動的プロジェクトのためGitHubPagesにはアップできないため、gifアニメを置いておきます。
- 銀河パターン
今後の展開予定
- Elixir側でシーンのパラメータの設定等の役割を増やす
- 他の技術でのチャレンジ
参考文献
乾燥した換装
実はWEB系フロントエンドツールはReact等も含めてほとんど触っていなかったので、プロジェクト構築にはだいぶ苦労しました。
この記事自体が備忘録になるので、今後もより深く構成を追求していきたいです。
おわりに
ここまでご閲覧いただきありがとうございます。
今後も思いつくままに記事投稿を続けて行きたい所存であります。
この世界は好都合に未完成、だから知りたいんだ。
