从0到1实现 Balatro 游戏后端(4):玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计

32 阅读6分钟

本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统
涵盖规则实现、架构设计、WebSocket 通信、模块拆分以及工程化演进

  • 项目地址:balatro-realtime-backend
  • 📌 对应代码版本:feat(game): implement play/discard logic and move dealCards to game layer(2026-05-08)
  • ⚠️ 本文基于该 commit ,仓库代码可能已更新

前面三篇,我分别做了三件事:

  • 把牌型判断从“想法”变成代码
  • 把代码从“脚本”升级成“架构”
  • 让服务器开始“记住状态”(deck / hand)

但到这里,我很快意识到一个问题:

❗ 玩家其实还“参与不了游戏”

系统可以发牌,但不能操作。

这意味着:

👉 这个项目还不能算是一个“游戏”,更像是一个工具集合。


所以这一篇,我要解决一个非常核心的问题:

🎯 让玩家真正参与进来,让状态开始“流动”


📚 系列文章:

(1)项目规划与牌型判断实现
(2)NestJS框架搭建与项目结构设计
(3)洗牌、发牌与服务端牌堆状态管理
(4)玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计(当前)
(5)得分计算与回合结算 (6)Blind关卡状态设计与回合推进实现
(7)Boss Blind与特殊规则实现(开发中)


一、本篇解决什么问题?

在前三篇中,我已经完成了:

  • ✔ 规则层(牌型判断)
  • ✔ 架构层(NestJS + 模块拆分)
  • ✔ 状态层(发牌 + 牌堆维护)

但缺少一个关键能力:

❗ 玩家操作

这一篇的目标就是:

👉 让系统从“能发牌”,变成“能操作”


二、本篇核心能力

这一阶段,我主要做了这些事情:

  • 实现出牌(play)与弃牌(discard)
  • 抽象 selectCards 统一操作入口
  • 实现自动补牌(remove + draw)
  • 将 dealCards 从 poker 迁移到 game(职责回归)
  • 将 GameState 归位到 game 层
  • 引入参数校验体系
  • 设计统一返回结构
  • 拆分错误码体系

一个明显的转折点

写到这里,我开始有一个很强的感受:

❗ 系统已经不再是“接口调用”,而是在围绕“状态流转”运行


三、为什么必须实现出牌 / 弃牌 / 补牌?

在只有发牌的情况下:

  • 玩家无法操作
  • 游戏无法推进
  • 状态不会变化

本质上:

👉 这还不是一个游戏


我把整个流程简单抽象了一下:

发牌 → 操作 → 状态变化 → 继续操作

如果缺少“操作”这一环:

👉 后面的所有设计都没有意义


四、dealCards 为什么必须迁移到 game?

一开始我把 dealCards 放在 poker 里,因为直觉上:

发牌 ≈ 牌操作

但在实现出牌/弃牌的时候,我发现一个问题:

❗ 发牌其实依赖“玩家状态”

比如:

  • 当前 hand
  • 剩余 deck
  • playsLeft
  • discardsLeft

这时候我才意识到:

👉 dealCards 本质是“流程行为”,而不是“规则计算”


最终我做的调整

  • game:负责流程 + 状态
  • poker:负责规则 + 工具

并且明确了一条原则:

❗ 依赖只能单向:game → poker
❗ 禁止反向调用


五、selectCards:统一抽象操作流程

从用户视角:

  • 出牌
  • 弃牌

是两种不同操作。


但在实现过程中,我发现:

👉 服务端处理流程其实是一样的

选牌 → 移除 → 补牌 → 更新状态


唯一的区别是:

操作消耗
playplaysLeft
discarddiscardsLeft

于是我做了一个抽象:

selectCards(
  selectedCards: string[],
  action: "play" | "discard",
  playerId: string
)

这样做的好处

  • 减少重复逻辑
  • 流程统一
  • 后续扩展(技能 / 特效)更容易

六、为什么必须做参数校验?

这一块一开始我其实是想“先不做”的。

但在调试过程中,很快就遇到各种问题:

  • 空数组
  • 非法牌
  • 重复选择
  • 选择不存在的牌
  • 超出数量限制
  • 次数耗尽

这时候我才意识到:

❗ 如果没有校验,整个系统是非常脆弱的


最终我加上的校验逻辑

if (!selectedCards?.length) { ... }
if (selectedCards.length > MAX) { ... }
if (!CARD_PATTERN.test(card)) { ... }
if (duplicate) { ... }
if (!existInHand) { ... }
if (playsLeft <= 0) { ... }

一个很重要的认知

👉 校验不是“代码冗余”,而是“系统边界”


七、补牌机制:真正的状态流

一开始我把:

  • 出牌
  • 弃牌
  • 补牌

当成三件事来看。


但在实现过程中,我发现:

❗ 它们其实是“一次状态更新”


完整流程是:

移除选中牌
↓
从 deck 抽牌
↓
更新 hand
↓
返回新状态

核心实现

private removeAndDrawCards(
  selectedCards: string[],
  handCards: string[],
  playerState: GameState
): string[] {

    const newHand: string[] = [];
    const deck = playerState.deck;

    for (let card of handCards) {
        if (!selectedCards.includes(card)) {
            newHand.push(card);
        }
    }

    const need = playerState.handSize - newHand.length;

    const draw = this.pokerService.serializeCards(
        deck.splice(0, need)
    );

    newHand.push(...draw);

    return newHand;
}

一个关键变化

👉 操作的结果,不是“提示”,而是“新状态”


八、返回结构设计

在设计返回数据时,我做了一个明确的约束:

❗ 前端只负责展示,不负责计算


所以返回结构被我拆成三类:


1️⃣ 状态数据

  • hand
  • playsLeft
  • discardsLeft
  • remainingDeckCount

2️⃣ 操作信息

  • selectedCards

(主要用于前端动画 / UI反馈)


3️⃣ 流程状态

  • gameOver

最终结构

type PlayCardsResult = {
    code: number;
    hand: string[];
    playsLeft: number;
    discardsLeft: number;
    remainingDeckCount: number;
    selectedCards: string[];
    gameOver: boolean;
};

九、错误码分层设计

一开始我也写过这种代码:

return 351;

短期很快,但很快就开始失控:

  • 不知道代表什么
  • 不好维护
  • 不可复用

后来我改成了分层设计:

RESULT_CODE
PLAYER_STATE_CODE
REQUEST_PARAM_CODE
GAME_FLOW_CODE

示例

EMPTY_SELECTED_CARDS: 351
CARD_NOT_IN_HAND: 353
NO_PLAYS_LEFT: 401

一个关键认知

👉 错误码本质上是“协议”,而不是数字


十、当前系统的局限

虽然现在已经可以:

  • 发牌
  • 出牌
  • 弃牌
  • 补牌

但还缺少一个关键部分:


游戏核心机制

  • 得分计算
  • 回合结算
  • 胜负判定

当前状态可以总结为:

👉 能玩,但还不知道输赢


十一、本篇本质总结

功能层

  • ✔ 操作能力(play / discard)
  • ✔ 自动补牌
  • ✔ 状态维护

架构层

  • ✔ GameState 归位
  • ✔ dealCards 迁移
  • ✔ selectCards 抽象
  • ✔ 错误码分层

系统层变化

❗ 从“发牌系统” → “游戏状态系统”


十二、下一步

到这里,我才真正进入“游戏核心逻辑”。

下一篇我会开始处理:

🎯 得分系统 + 回合结算

包括:

  • 牌型接入
  • 分数计算
  • 是否达标
  • 游戏结束判断

结语

这一篇最关键的,其实不是“怎么写出牌逻辑”。

而是:

👉 我开始在实现“游戏循环”


走到这里,我能明显感觉到这个项目发生了一次质变:

✔ 从工具函数 → 状态系统
✔ 从接口调用 → 游戏流程
✔ 从逻辑实现 → 玩法驱动


接下来,就是让这个系统“真正好玩起来”的阶段了 🚀