发布于 2026-01-06 1 阅读
0

使用 Phoenix LiveView 构建和玩围棋

使用 Phoenix LiveView 构建和玩围棋

欢迎回到 Elixir Alchemy 系列!这次,我们将通过构建一个交互式游戏来探索Phoenix LiveView的强大功能。

LiveView 通过在服务器端渲染 HTML 并通过 WebSocket 在前端和后端之间进行通信,帮助我们构建实时界面,而无需编写 JavaScript 代码或担心浏览器端的状态更新。通过在服务器端更新状态,LiveView 确保只更新页面中需要更新的部分,从而实现快速运行且网络传输数据量极少的应用程序。

为了说明这一点,我们将使用 Phoenix 构建游戏,并使用 LiveView 实现交互功能。虽然井字棋很有趣,但我们将挑战更高难度的游戏——围棋

围棋是一种抽象策略棋类游戏,供两名玩家对弈,目标是占领比对手更多的领土。该游戏起源于2500多年前的中国,被认为是现今仍在持续流行的最古老的棋类游戏。

最终实现的围棋游戏允许玩家轮流在棋盘上落子。玩家可以吃掉对方的棋子,游戏会记录哪些棋子被吃掉了。

使用 Phoenix LiveView 实现的围棋游戏

在此过程中,我们将学习Phoenix LiveView如何通过保持所有代码都在 Elixir 中,帮助您构建交互式应用程序,而无需在前端和后端之间重复编写代码。

我们走吧!

初始应用程序一个 Phoenix 应用程序,其中部分组件已经设置完毕。它清理了一些生成的文件,为围棋棋盘添加了样式,根据 README 中的安装指南预安装了 Phoenix LiveView 并且包含一个用于跟踪游戏状态的模块。

在我们的游戏中,该State模块用于描述游戏状态。初始应用已经包含了该State模块,因此我们可以专注于使用 LiveView。

State结构体在其列表中跟踪棋盘上有哪些棋子:positions,并且在其键中知道下一个玩家是谁:current

# lib/hayago/state.ex
defmodule Hayago.State do
  alias Hayago.State
  defstruct positions: Enum.map(1..81, fn _ -> nil end), current: :black

  # ...
end

新创建的状态会被初始化为一个包含 81 个nil值的列表:positions,因为棋盘是空的,只有 9x9 个位置。持有黑棋的玩家先走,所以对于任何新状态,:current键值都设置为 0 。:black

State模块提供了两个函数。第一个函数place/2用于在棋盘上放置一个新棋子。如果新放置的棋子完全占据了另一个棋子的自由度,则被占据的棋子会自动从棋盘上移除。

legal?/2函数通过检查该位置是否已有其他棋子以及确保该棋子不会被立即吃掉来检查移动是否合法。

-> 如果您想了解更多关于State我们将要使用的模块的实现信息,请查看该模块的文档,其中解释了它如何处理在棋盘上放置棋子、吃掉棋子以及验证可能的移动。

模块GameLive

我们首先渲染棋盘。首先,我们在应用程序中添加一个实时视图来处理棋盘的渲染和更新。它被命名为 `<div class="board">` GameLive,并且它有 `on`render/1和 ` mount/2on` 回调函数。

# lib/hayago_web/live/game_live.ex
defmodule HayagoWeb.GameLive do
  use Phoenix.LiveView

  def render(assigns) do
    HayagoWeb.GameView.render("index.html", assigns)
  end

  def mount(_session, socket) do
    {:ok, assign(socket, state: %Hayago.State{})}
  end
end

回调mount/2函数会设置socket视图的初始状态。我们用它来创建一个新状态,然后将其添加到 socket 赋值中,使其在模板中可用。

接下来,我们将添加一个用于渲染看板的模板。我们将其命名为 `<template_name>` index.html.leex,使其成为一个实时 EEx模板。虽然与常规 EEx 模板类似,但实时模板会跟踪更改,以便在视图更新时通过网络发送最少的数据。

# lib/hayago_web/templates/game/index.html.leex
<div class="board <%= @state.current %>">
  <%= for _position <- @state.positions do %>
    <button></button>
  <% end %>
</div>

我们遍历@state实时视图中分配的结构体中的所有位置,并<button>为每个位置创建一个空元素。按钮位于一个<div>元素中,样式表会自动将其样式设置为围棋棋盘。我们还会将当前颜色作为类名添加到棋盘中,以便样式表在鼠标悬停在某个位置时显示即将落子的颜色。

最后,我们将所有请求路由到路由器中的/我们的模块:GameLive

# lib/hayago_web/router.ex
defmodule HayagoWeb.Router
  # ...

  scope "/", HayagoWeb do
    pipe_through :browser

    live "/", GameLive
  end
end

如果我们启动 Phoenix 服务器,并在浏览器中访问https://localhost:4000,就会看到应用程序渲染出一个空白的围棋棋盘。虽然我们现在还不能下棋,但将鼠标悬停在棋盘上的位置,就能看到新棋子应该落在哪里。

采取行动

为了在棋盘上放置棋子,State它实现了一个place/2函数,该函数接受一个State结构体和一个索引。它将索引对应的位置替换为:current键中的值,该值要么是“是”,要么是:black“否:white”,具体取决于轮到哪位玩家。

在模板中,我们为按钮添加了phx-click属性phx-value。这些属性指示 LiveView 向我们的模块发送事件GameLive

# lib/hayago_web/templates/game/index.html.leex
<div class="board">
  <%= for {value, index} <- Enum.with_index(@state.positions) do %>
    <button phx-click="place" phx-value="<%= index %>" class="<%= value %>"></button>
  <% end %>
</div>

在实时视图中,我们通过匹配模板中设置的属性来处理事件。我们使用传递的索引来调用 `getState()` 方法State.place/2,该方法返回一个新状态,其中棋子已放置在棋盘上。我们将返回一个:noreply包含新状态的元组。

# lib/hayago_web/live/game_live.ex
defmodule HayagoWeb.GameLive do
  # ...

  def handle_event("place", index, %{assigns: assigns} = socket) do
    new_state = State.place(assigns.state, String.to_integer(index))
    {:noreply, assign(socket, state: new_state)}
  end
end

通过更新 socket 中的状态,LiveView 可以知道需要重新渲染模板中已更改的部分。然后,将更新后的页面与已渲染的页面进行比较,以便对已渲染的页面应用最小的修改。

回到浏览器后,页面在我们修改后自动刷新,我们的项目看起来已经像一个真正的围棋游戏了。我们可以在棋盘上放置棋子,甚至可以包围对方的棋子并将其移除!

看看我们省去了多少麻烦!我们无需编写任何代码向浏览器发送数据,也无需担心页面渲染的更新。LiveView 会在状态改变时自动更新页面。

但是,如果我们两次点击同一个位置,已经放置的棋子就会被另一颗棋子替换。为了防止这种情况发生,我们应该禁用代表非法移动的按钮。

防止非法行为

为了防止非法走法,对于已经放置棋子的位置,或者新放置的棋子没有自由落体的位置,我们将禁用按钮。

为了实现这一点,我们将使用State.legal?/2,它接受当前状态和索引,并返回一个值,指示当前玩家是否可以在那里放置石头。

<div class="board <%= @state.current %>">
  <%= for {value, index} <- Enum.with_index(@state.positions) do %>
    <%= if Hayago.State.legal?(@state, index) do %>
      <button phx-click="place" phx-value="<%= index %>" class="<%= value %>"></button>
    <% else %>
      <button class="<%= value %>" disabled="disabled"></button>
    <% end %>
  <% end %>
</div>

由于 LiveView 负责页面更新,我们可以在模板中添加一个 if 语句,检查在每个位置放置棋子是否合法。如果合法,则渲染与之前相同的按钮;否则,渲染一个禁用的按钮。

样式表确保禁用按钮不显示悬停效果,并将光标更改为指示可以在此处放置石头。

捕捉石头

当吃掉一颗棋子时,该place/2函数会递增当前状态:captures映射中的一个计数器,该映射保存着黑棋和白棋的计数器。

对于每个被吃掉的棋子,我们会在棋盘上方显示一个棋子。由于吃子记录已经存在于@state我们从实时视图获取的结构体中,我们将遍历每个计数器,并渲染一个<span>具有正确类名的元素,以便样式表将其转换为按钮。

<div class="captures">
  <div>
    <%= for _ <- 1..@state.captures.black, @state.captures.black > 0 do %>
      <span class="black"></span>
    <% end %>
  </div>
  <div>
    <%= for _ <- 1..@state.captures.white, @state.captures.white > 0 do %>
      <span class="white"></span>
    <% end %>
  </div>
</div>

由于我们在列表推导式中使用了范围,因此我们将确保添加一个过滤器,以确保在遍历列表时列表不为空。

现在,我们可以追踪哪些棋子被吃掉了,因为棋子会显示在棋盘上方。

得分、历史和决胜规则

我们在 Go 语言的实现方面取得了一些进展,现在已经学会了如何搭建 LiveView 项目。我们发现,只要精心更新状态,就足以构建一个界面,而无需担心视图的更新。因此,我们专注于渲染当前状态的静态表示,并将更新玩家所见页面的工作交给 LiveView 处理。除了搭建 LiveView 模块之外,我们编写的用于显示棋盘状态的代码全部是通过向模板添加逻辑来实现的,无需编写任何 JavaScript 代码。

不过,我们还有一些事情要做。下次见面时,我们会为游戏添加历史记录功能,允许玩家撤销操作;我们还会引入劫争规则,防止重复操作。最后,我们会添加一个分数计数器,显示每位玩家的当前分数。下次见!

文章来源:https://dev.to/appsignal/building-and-playing-the-go-game-with-phoenix-liveview-58g