返回文章

联机牌桌为什么需要一套真正的准备状态机

从自动开局的边界问题出发,重构多人牌桌的准备流程,并复盘一次界面布局的反复修复。

6 分钟阅读#Node.js#状态机#多人游戏#前端布局
联机牌桌为什么需要一套真正的准备状态机

最近在开发一个带服务端、登录、房间和 AI 玩家功能的联机牌桌原型。功能逐渐增加后,一个最初看起来很自然的设计开始频繁出错:人数够了就自动开局。

创建房间、真人加入、添加 AI、断线重连,这些事件都可能改变阵容。如果它们各自直接调用 startHand(),开局规则就会散落在多个入口里,玩家也无法确认自己是否准备好了。

自动开局为什么会失控

最初的准备逻辑只存在于一手结束后的结算阶段。首次进入房间时,只要添加一个 AI 或邀请到第二名玩家,服务端就会立即发牌。

问题不只是体验突然,还包括状态一致性:

  • 有人准备后,新玩家加入,旧准备是否仍然有效?
  • 房主增减 AI,其他人是否同意以新阵容开始?
  • 玩家断线重连,能否沿用断线前的准备?
  • 邀请动作和被邀请人真正入桌,应该触发同一种事件吗?

只在每个入口补一个条件,会让规则越来越难证明完整。更合理的做法是把“等待准备”提升为牌桌的正式阶段。

让准备成为唯一开局入口

重构后的规则很简单:

等待准备
  -> 所有在线真人已准备
  -> 当前总座位数不少于 2
  -> 开始发牌

AI 默认视为已准备,但不能替真人做决定。创建房间、玩家加入、玩家退出、断线重连以及 AI 阵容变化,都只更新阵容并清空真人准备状态,不再直接发牌。

服务端中所有自动 startHand() 调用被收敛到准备状态机内部。这样可以用一个明确的不变量描述系统:没有经过全员准备检查,就不可能开始一手牌。

邀请本身不会改变牌桌;被邀请人真正加入时才属于阵容变化。这个区分也很重要,否则发送一个链接就可能意外重置正在等待的玩家状态。

用关键路径证明状态机

状态机不能只靠点击页面验证。重构后重点覆盖了几条路径:

  1. 创建房间后添加 AI,不应自动开局;
  2. 第二名真人加入,不应自动开局;
  3. 已有人准备后改变阵容,所有真人准备应清零;
  4. 只有全部在线真人准备后才发牌;
  5. 本手结算后,下一手仍需重新准备;
  6. 等待阶段断线重连,不沿用旧阵容的准备结果。

测试时使用隔离数据和独立端口,避免污染正在运行的正式实例。除了行为断言,还检查服务端源码中的 startHand() 调用位置,确认不存在能绕开状态机的隐藏入口。

前端也要表达真实阶段

首次开局和本手结算是两个不同场景。前者新增独立的“等待开局”面板,显示玩家准备状态;后者保留结算结果,并在其上继续下一手准备流程。

准备面板只覆盖牌桌中央,不挡住房主的邀请与 AI 管理。阵容改变后,界面立即显示所有真人重新确认,避免服务端已经重置、客户端却仍给出旧状态的错觉。

同一轮工作还补充了本手累计投入显示,以及数字输入与滑块联动的下注控件。服务端已经维护累计下注字段,因此前端直接复用,不再复制一套容易偏离的计算逻辑。

一次反复出现的布局问题

牌桌左右两侧有信息栏。最初用三列 CSS Grid 实现收起功能,把隐藏侧栏的列宽压成零。实际窗口和缩放组合下,主桌仍按旧网格位置计算,出现牌桌缩到左侧、公共牌与座位分离,甚至收起后无法恢复的问题。

第一次修复尝试为四种收起状态分别定义网格列;它改善了部分情况,但主桌仍然受到侧栏布局流影响。

最终不再继续修补网格,而是改变结构:

  • 主桌始终是独立的全宽画布;
  • 左右信息栏变成覆盖式抽屉;
  • 收起只改变抽屉可见性,不改变主桌尺寸和坐标;
  • 顶部提供永久可见的恢复入口;
  • 刷新后回到完整布局,不保存可能损坏体验的折叠状态。

这次反复说明,稳定布局的关键不是写出更多状态选择器,而是让不相关的元素停止互相参与尺寸计算。

规则显示也需要统一出口

随着 Token 数量增大,玩家端和管理端都需要 k、M、B 的短格式。两端原本各有格式化函数,若只改一边,余额、底池和后台账号列表会显示成不同规则。

最终统一了阈值和尾随零处理,并覆盖 1k、1.5k、1M、1B 等边界样例。特殊房间的下注上限也被收敛到同一规则函数中:界面可以显示“无限”,服务端仍强制不能超过玩家持有的 Token。

多人游戏里,前端提示只能帮助理解,真正的规则必须由服务端集中执行。

复盘

这段开发最重要的变化,是从“给事件加条件”转向“定义合法状态转换”。当加入玩家、添加 AI、重连和结算都围绕同一个准备阶段运行时,行为才变得可解释、可测试。

界面布局也有类似规律:当一个结构需要越来越多例外才能维持,就应该重新考虑元素之间的关系,而不是继续堆补丁。

功能复杂度不会因为隐藏在事件处理器或 CSS 选择器里而消失。把它提升成清晰的状态与边界,才是真正的简化。