使用 Elixir 和 LiveView 构建 Game of Islands

发布: (2025年12月17日 GMT+8 22:51)
10 min read
原文: Dev.to

Source: Dev.to

什么是 Islands Duel?

Islands Duel 是一个双人策略游戏,类似经典的 Battleship 游戏。

  • 设置 – 每位玩家有一个 11 × 11 的棋盘,上面放置了 5 个隐藏的岛屿。
  • 游戏流程 – 玩家轮流猜测对手棋盘上的坐标。
  • 命中或未命中 – 如果你猜中的格子包含岛屿的一部分,则为 命中;否则为 未命中
  • 森林化 – 当一个岛屿的所有格子都被命中时,该岛屿被 森林化(摧毁)。
  • 胜利 – 第一个将对手所有岛屿森林化的玩家获胜!

Islands Duel 演示

在深入技术细节之前,你可以在这里尝试游戏:

https://islands-duel.fly.dev/

你也可以在这里找到本文的代码:

https://github.com/hungle00/islands_duel

为什么我会构建它

我正在阅读 Lance Halvorsen 的《Functional Web Development with Elixir, OTP, and Phoenix》一书学习 Elixir。该书教你如何构建一个游戏引擎,但并未提供完整的网页 UI。因此,我决定使用 Phoenix LiveView 来自行实现一个 UI!

为什么选择 LiveView?

LiveView 让你能够在 不编写 JavaScript 的情况下构建交互式、实时的网页应用。所有逻辑都在 Elixir 服务器上运行,LiveView 会通过 WebSocket 自动同步 UI 与浏览器。针对这个游戏,我 没有写一行 JavaScript 来实现游戏逻辑——所有点击、棋盘更新以及玩家同步都纯粹使用 Elixir 完成。这就是 LiveView 的魔力所在!

步步构建游戏

让我带你了解我是如何构建这个游戏的。即使你是 Elixir 新手也不用担心——我会用通俗的语言解释每一步。

步骤 1:让用户加入游戏

  1. 玩家 1 点击 “Start a game” → 创建一个带有唯一 game_id 的新游戏。
  2. 玩家 1game_id 分享给 玩家 2
  3. 玩家 2 输入 game_id → 加入同一个游戏。

用户加入游戏

当用户访问游戏页面时,我们会检查游戏是否已存在。如果不存在,就创建它,然后把玩家加入游戏。

# Check if game exists
game_pid = GenServer.whereis(Game.via_tuple(game_id))

if game_pid == nil do
  # Game doesn't exist, create it and join as Player 1
  GameSupervisor.start_game(game_id)
  Game.add_player(Game.via_tuple(game_id), username)
else
  # Game exists, join as Player 2
  Game.add_player(Game.via_tuple(game_id), username)
end

什么是 GameSupervisor

GameSupervisor 想象成一个管理者,负责监控所有游戏。每个游戏都以独立进程(类似于你应用中的小程序)运行。监督者在玩家启动游戏时创建新的游戏进程,并跟踪所有正在运行的游戏。

步骤 2:使用 mount 设置游戏界面

当玩家打开游戏页面时,LiveView 会调用名为 mount 的函数。把它看作 初始化 阶段——我们准备玩家需要看到的一切。

mount 中的工作

  • 获取玩家的名字和游戏 ID。
  • 加载游戏状态(轮到谁、玩家名字等)。
  • 在屏幕上绘制两个棋盘。
def mount(_params, session, socket) do
  game_id  = session["game_id"]
  username = session["current_user"].name

  # Save this info so we can use it later
  socket =
    socket
    |> assign(:game_id, game_id)
    |> assign(:username, username)
    |> assign(:player1_name, nil)
    |> assign(:player2_name, nil)
    |> assign(:player_turn, nil)

  # Only do "live" stuff when the browser is connected
  if connected?(socket) do
    socket = join_game(socket, game_id, username)
    {:ok, socket}
  else
    {:ok, socket}
  end
end

为什么要使用 connected?(socket)

LiveView 会运行两次:一次生成初始页面的 HTML,另一次在浏览器通过 WebSocket 连接时运行。我们只想在浏览器真正连接后才加入游戏,而不是在生成初始 HTML 时就加入。

绘制棋盘

在模板(.heex 文件)中,我们使用一个简单的循环绘制两个 11 × 11 的网格:

<%= for {row, row_idx} <- Enum.with_index(@board) do %>
  <%= for {cell, col_idx} <- Enum.with_index(row) do %>
    <div
      phx-click="cell_click"
      phx-value-row={row_idx}
      phx-value-col={col_idx}
      class={"cell #{cell_class(cell)}"}>
    </div>
  <% end %>
<% end %>

每个格子都有 phx-click="cell_click",这会告诉 LiveView 在点击时调用我们的 handle_event 函数,而 phx-value 则提供该格子的坐标。

步骤 3:使用 handle_event 处理玩家点击

当玩家点击对手棋盘上的格子时,LiveView 捕获该点击并调用 handle_event。这里我们处理玩家的猜测:

def handle_event("cell_click", %{"row" => row, "col" => col}, socket) do
  game_id        = socket.assigns.game_id
  current_player = socket.assigns.player_role

  # Ask the game engine: is the guess a hit or a miss?
  case Game.make_guess(game_id, current_player, {row, col}) do
    {:hit, updated_state} ->
      socket
      |> assign(:game_state, updated_state)
      |> push_event("guess_result", %{result: "hit", row: row, col: col})

    {:miss, updated_state} ->
      socket
      |> assign(:game_state, updated_state)
      |> push_event("guess_result", %{result: "miss", row: row, col: col})
  end
end

Game.make_guess/3 会判断这次猜测是命中还是未命中,并返回更新后的游戏状态。

函数(在游戏引擎中实现)用于判断坐标是否击中岛屿,更新内部状态,并返回新的状态以及结果。随后,LiveView 会向客户端推送事件,以便 UI 更新单元格的外观。

总结

  • LiveView 让我们将所有游戏逻辑保留在服务器上,省去了自定义 JavaScript 的需求。
  • GenServer 进程(每局游戏一个)负责核心游戏状态,而 Supervisor 则保持这些进程的存活和组织。
  • 结果是一个流畅、实时的双人体验,感觉像经典的桌面游戏——只是在浏览器中进行。

尝试一下演示,探索源代码,随意在 Islands Duel 上加入你自己的创意!

是命中还是未命中?

case Game.guess_coordinate(Game.via_tuple(game_id), current_player, row, col) do
  {:hit, island, win_status} ->
    # We hit an island!
    socket = mark_cell_as_hit(socket, row, col)
    {:noreply, socket}

  {:miss, :none, :no_win} ->
    # We missed
    socket = mark_cell_as_miss(socket, row, col)
    {:noreply, socket}

  :error ->
    # Not our turn or invalid move
    {:noreply, put_flash(socket, :error, "Not your turn!")}
end

点击数据如何传递到我们的函数

LiveView 提供了称为 bindings 的特殊 HTML 属性,用于将模板连接到 Elixir 代码:

BindingDescription
phx-click="cell_click"当点击此元素时,在 LiveView 中调用 handle_event("cell_click", …)
phx-value-row={row}row 值附加到点击事件中
phx-value-col={col}col 值附加到点击事件中

这些值会自动收集并作为第二个参数传递给 handle_event%{"row" => "5", "col" => "3"})。无需 JavaScript——LiveView 会处理一切!

步骤 4:使用 handle_info 更新屏幕

当玩家 1 做出一步时,玩家 2 如何看到?他们在不同的浏览器!LiveView 为此提供了 handle_info/2 回调。它接收来自系统其他部分的消息。当玩家 1 做出猜测时,我们向玩家 2 的 LiveView 发送一条消息,handle_info/2 负责处理:

def handle_info({:guess_result, result}, socket) do
  # Another player made a guess, update our screen
  socket =
    socket
    |> update_board_with_result(result)
    |> put_flash(:info, "#{result.player_name} guessed (#{result.row}, #{result.col})")

  {:noreply, socket}
end

另一个例子:当玩家 2 加入游戏时,我们通知玩家 1:

def handle_info({:player_added, %{username: username}}, socket) do
  # A new player joined the game!
  socket =
    socket
    |> load_game_state(socket.assigns.game_id)
    |> put_flash(:info, "#{username} joined the game!")

  {:noreply, socket}
end

第5步:使用 PubSub 连接两位玩家

PubSub 的工作方式类似于群聊:

  1. 订阅 – 当玩家加入游戏时,他们会“订阅”该游戏的频道。
  2. 广播 – 当发生某些事件(如猜测、玩家加入)时,我们会向所有已订阅的玩家“广播”一条消息。

订阅(在 mount/3 中)

if connected?(socket) do
  # Join the game's "chat room"
  topic = "game:#{game_id}"
  Phoenix.PubSub.subscribe(IslandsDuel.PubSub, topic)
end

广播(猜测之后)

# Tell everyone in this game what happened
Phoenix.PubSub.broadcast(
  IslandsDuel.PubSub,
  "game:#{game_id}",
  {:guess_result, %{player: current_player, row: row, col: col, result: :hit}}
)

当我们广播时,所有订阅了 "game:#{game_id}" 的玩家都会在各自的 handle_info/2 回调中收到该消息。这就是两位玩家保持同步的方式!

流程

玩家 1 点击一个格子

handle_event 处理猜测

broadcast 向所有玩家发送消息

玩家 2 的 handle_info 接收该消息

玩家 2 的界面自动更新

结论

构建 Islands Duel 让我认识到 LiveView 让实时功能出奇地简单。我们不需要编写复杂的 JavaScript,只需:

  • 使用 mount/3 来设置页面。
  • 使用 handle_event/3 来处理用户操作。
  • 使用 handle_info/2 + PubSub 在玩家之间同步。

如果你正在学习 Elixir 并想找个有趣的项目,试着做一个简单的多人游戏。你将学习 LiveView、PubSub 和实时 Web 应用——同时还能玩得开心!

游戏引擎代码取自《Functional Web Development with Elixir, OTP, and Phoenix》(Pragmatic Bookshelf)。如果你想系统地学习 Elixir,我强烈推荐这本书。

祝编码愉快! 🎮

Back to Blog

相关文章

阅读更多 »