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

  • 本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统。包括规则实现、架构设计、WebSocket 通信、模块拆分以及后续工程化改造。
  • GitHub地址: https://github.com/RainbowZhou93/balatro-realtime-backend
    • 📌 本文对应代码版本:commit: feat(game): implement play/discard logic and move dealCards to game layer(2026年5月8日 14:17)
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该 commit 版本进行说明。

✅本篇实现了什么

本篇完成了玩家手牌操作能力的构建,使系统从"只能发牌"演进为"可交互的游戏流程":

  • 实现出牌(play)与弃牌(discard)逻辑
  • 引入 selectCards 抽象,统一处理手牌操作流程
  • 实现自动补牌机制(remove + draw),维持手牌数量
  • dealCardspoker 迁移至 game,明确模块职责
  • 引入并迁移 GameState,由服务端统一维护玩家状态
  • 增加多层参数校验,保证输入安全与状态一致性
  • 设计返回结构,使前端基于服务端状态进行渲染
  • 实现错误码分层,提升错误定位与可维护性

👉 本篇的核心变化是:让游戏从"功能调用"升级为"状态驱动"。

文章目录

  • [一、为什么要实现出牌 / 弃牌 / 补牌](#一、为什么要实现出牌 / 弃牌 / 补牌)
  • [二、为什么 dealCards 要从 poker 移到 game](#二、为什么 dealCards 要从 poker 移到 game)
    • [1. dealCards的迁移](#1. dealCards的迁移)
    • [2. GameState是否同步到/game下?](#2. GameState是否同步到/game下?)
  • [三、统一抽象 selectCards:出牌与弃牌的公共流程](#三、统一抽象 selectCards:出牌与弃牌的公共流程)
    • [1. 公共流程说明](#1. 公共流程说明)
    • [2. 核心实现:selectCards](#2. 核心实现:selectCards)
  • 四、参数校验:为什么不能相信前端
    • [1. 参数校验说明](#1. 参数校验说明)
    • [2. 代码结构实现](#2. 代码结构实现)
  • 五、自动补牌:从操作到状态更新
    • [1. 补牌流程说明](#1. 补牌流程说明)
    • [2. 核心实现:removeAndDrawCards](#2. 核心实现:removeAndDrawCards)
  • 六、返回结果设计
  • 七、错误码分层:让失败原因更清晰
    • [1. 分层设计说明](#1. 分层设计说明)
    • [2. 当前错误码实现](#2. 当前错误码实现)
  • 八、当前实现的局限
  • 九、本篇小结
    • [1. 功能层面总结](#1. 功能层面总结)
    • [2. 结构层面总结](#2. 结构层面总结)
    • [3. 下一步会发生什么](#3. 下一步会发生什么)

一、为什么要实现出牌 / 弃牌 / 补牌

在前面的几篇中,我们已经完成了牌型判断、洗牌以及发牌流程的实现。虽然当前的功能已经可以独立运行,但整体仍然停留在"模块能力"的阶段,而不是一个完整的游戏流程。

从游戏设计的角度来看,Balatro 本质是一个回合制游戏。在一轮完整的回合中,玩家不仅需要看到手牌,还需要对手牌进行操作,并基于这些操作持续参与游戏。

然后,在当前实现中,系统只具备发牌能力,玩家无法:

  • 选择出牌
  • 调整手牌
  • 持续参与回合流程

整个流程更像是发完一手牌后结束,而不是一个可以交互和推进的游戏过程。

因此,我们需要引入三类能力:

  • 玩家操作:支持出牌和弃牌
  • 状态维护:在操作后自动补牌,保持手牌数量
  • 节奏控制:让玩家能够持续参与回合,而不是一次性结束

👉 基于以上需求,本篇将实现出牌、弃牌以及补牌的核心逻辑,为后续回合结算与得分系统打下基础。

二、为什么 dealCards 要从 poker 移到 game

1. dealCards的迁移

上一篇我们把 dealCards 暂时归到了 poker,当时考虑的是:发牌的本质是对牌堆的裁剪与分发,属于牌操作的一部分。

但在实现本篇功能的过程中,我逐渐发现 dealCards 实际更像是游戏开始的初始化操作,依赖的是玩家状态(handdeck)以及游戏流程控制。

例如:

  • 初始化玩家手牌( hand )
  • 维护剩余牌堆( deck )
  • 控制出牌/弃牌次数

这说明,dealCards 已经不再是一个纯粹的扑克牌操作,而是一个与游戏流程强相关的行为。

因此,我将 dealCardspoker 层迁移到了 game 层,由 game 统一负责玩家状态与流程控制。

需要注意的是 ,这种迁移并不意味着将所有逻辑都堆到 game 中。

在当前实现中:

  • game 负责游戏流程与玩家状态
  • poker 仍然负责所有与牌本身相关的能力

例如:

  • 牌结构( Card
  • 牌格式校验( CARD_PATTERN
  • 序列化( serialize

这些依然保留在 poker 模块中,由 game 进行调用,而不是迁移到 game 内部。因为 game 负责的是游戏的流程,但不应该吞掉 poker 的职责。

从主体来讲,一切的分发都是从 game 发出,不要做代码倒扣的情况。比如 poker 去调用 game 这种边界感混乱的操作,要从最初就禁止。

依赖方向必须单向,否则会导致模块边界混乱,甚至产生循环依赖问题。

2. GameState是否同步到/game下?

同时,上一篇中定义的 GameState 也随之从 poker 迁移到了 game

原因很简单:GameState 记录的是玩家当前游戏状态,例如手牌、剩余牌堆、出牌次数和弃牌次数。它描述的不是"牌本身",而是"玩家在游戏中的状态"。

因此,GameState 更适合归属于 game 模块,而不是 poker 模块。

其实写到这里的时候,我也开始逐渐意识到:项目刚开始的时候,模块边界问题并不会特别明显

但随着:

  • 玩家状态
  • 出牌流程
  • 回合控制
  • 后续的得分、BlindModifier

这些东西不断增加后,"这个逻辑到底该放哪层",会越来越重要。包括:

  • dealCards 是否应该放在 game
  • GameState 是否应该归属于 poker
  • 模块之间能不能反向调用

这些问题,很多时候其实并没有绝对标准答案。

我目前更偏向:

  • 流程归 game
  • 牌能力归 poker
  • 并且尽量保持单向依赖。

❓不知道大家在做类似项目时,会怎么处理这种模块边界问题。

三、统一抽象 selectCards:出牌与弃牌的公共流程

1. 公共流程说明

从用户的角度看,出牌和弃牌是两种操作行为。

但从服务端实现来看,出牌和弃牌都是根据用户选择的手牌进行移除,然后再从牌堆中补充相同数量的牌。

两者的差异仅体现在状态的消耗上:

  • 出牌消耗 playsLeft
  • 弃牌消耗 discardsLeft

所以我们本次是统一抽象为一个方法,这样可以统一处理流程,减少重复代码。然后再通过 action 参数区分具体行为。

2. 核心实现:selectCards

ts 复制代码
selectCards(selectedCards: string[], action: "play" | "discard", playerId: string): PlayCardsResult {
  // 校验
  // remove + draw
  // 扣次数
  // 返回结果
}

四、参数校验:为什么不能相信前端

1. 参数校验说明

在本次 selectCards 的实现中,我增加了多层参数校验逻辑。

从代码层面来看,这些校验主要包括:

  • 参数是否存在
  • 选择的牌是否为空
  • 是否为合法牌格式
  • 是否真实存在于当前手牌中
  • 是否存在重复选择
  • 是否超过数量限制
  • 当前是否还有可用操作次数

从视觉上看,这部分代码主要由一系列 if-else 组成,可能会显得较为冗长。但实际上,这些逻辑并不是冗余,而是服务端必须具备的防御性校验。

在服务端设计中,一个基本的原则是:不能信任来自前端的任何输入

因为所有参数都可能被篡改或构造,如果不在进入核心逻辑之前进行完整的校验,就可能导致状态异常甚至逻辑错误。

因此,这些校验的目的不是避免写代码,而是将所有潜在问题提前拦截在入口阶段,保证后续流程的稳定性。

2. 代码结构实现

以下为核心校验逻辑的结构示例(省略具体实现):

ts 复制代码
// 是否选择了牌
if (!selectedCards?.length) { ... }

// 是否超过最大选择数量
if (selectedCards?.length > GAME_RULE.MAX_SELECT_CARDS) { ... }

// 是否为合法牌格式(如 "AH", "10D")
if (!CARD_PATTERN.test(card)) { ... }

// 是否存在重复选择
if (selectedSet.size !== selectedCards.length) { ... }

// 是否真实存在于当前手牌中
if (!existCards) { ... }

// 当前是否还有可操作次数
if (playerState.playsLeft <= 0) { ... }

...

五、自动补牌:从操作到状态更新

1. 补牌流程说明

但玩家出牌或者弃牌结束后,我们需要根据对应牌数,补全用户手牌,维持手牌数量,保证回合可持续进行。

在本次实现中,我们主要是做的:

  • 从用户手中移除用户所选的牌
  • 从牌堆再次 splice 牌数补到用户的手牌中(这里使用 splice 是为了直接修改服务端维护的牌堆状态,保证牌不会被重复抽取)

也就是说,从出牌/弃牌到补牌,并不是独立的,这是相辅相成的状态流,一次完整的状态更新。

这样处理后,前端拿到的不是操作结果的提示,而是服务器在处理完流程后返回的用户手牌。

因此, removeAndDrawCards 的作用并不是简单的返回几张新牌,而是完成一次完整的手牌状态流转

  • 移除已选择的牌
  • 从服务端牌堆中补牌
  • 更新玩家当前手牌
  • 返回补牌后的最新状态

这一步完成后,出牌和弃牌才真正从"接口调用"变成了"游戏状态"。

2. 核心实现:removeAndDrawCards

ts 复制代码
    private removeAndDrawCards(selectedCards: string[], handCards: string[], playerState: GameState): string[] {
        const newHand: string[] = [];
        const deck: Card[] = playerState.deck;

        // 移除选中的手牌
        for (let i = 0; i < handCards.length; i++) {
            if (!selectedCards.includes(handCards[i])) {
                newHand.push(handCards[i]);
            }
        }

        // 计算需要补牌的数量
        const getSize: number = playerState.handSize - newHand.length;

        // 从牌堆中抽牌(splice 会直接修改 deck)
        const getDeck: string[] = this.pokerService.serializeCards(deck.splice(0, getSize));

        //合并新手牌
        newHand.push(...getDeck);
        return newHand;
    }

六、返回结果设计

1. 返回结果说明

在服务端设计中,一个基本原则是:

前端依赖服务器端的数据做为唯一的状态来源。

也就是说,前端在对外展示的时候,所依赖的数据应尽量由服务端返回,而不是自行维护。

因此,在设计返回结果时,可以将数据分为三类:

(1)状态数据(hand / playsLeft 等)

这些数据会随着出牌、弃牌行为发生变化,是前端渲染的核心依据:

例如

  • 剩余出牌次数( playsLeft
  • 剩余弃牌次数( discardsLeft
  • 当前手牌( hand
  • 牌堆剩余数量( remainingDeckCount

这些数据必须在每次操作后由服务端返回,以保证前端状态的一致性。

(2)操作信息:本次行为的上下文

除了状态数据之外,还可以返回本次操作相关的信息,例如:

  • 本次选择的牌( selectedCards

这类数据不会影响游戏状态,但可以帮助前端进行动画展示或操作反馈,而不需要额外维护临时数据。

(3)流程控制:游戏状态判定

关于游戏是否结束( gameOver )的判定,也应由服务端统一控制。

游戏的结束条件(例如是否达成目标、是否耗尽操作次数)都属于游戏规则的一部分,因此应在服务端进行判断,并在操作完成后返回结果。

2. 核心实现:PlayCardsResult

ts 复制代码
export type PlayCardsResult = {
    code: number; // 操作结果状态码
    hand: string[]; // 用户操作后新手牌是什么
    playsLeft: number; // 出牌剩余次数
    discardsLeft: number; // 弃牌剩余次数
    remainingDeckCount: number; // 牌堆剩余的数量
    selectedCards: string[]; // 用户选择操作的牌有哪些
    gameOver: boolean; // 游戏是否结束
};

七、错误码分层:让失败原因更清晰

1. 分层设计说明

错误代码分层是让代码更清晰。如果是用数字直接表示会是什么样子的?

比如用户没有选择牌,前端让参数 selectedCards 为空,那么在参数校验的时候,我们就会进行拦截返回。

如果直接在业务代码中写数字,短期看起来很方便:

ts 复制代码
        if (!selectedCards?.length) {
            return this.buildSelectCardsResult(351, selectedCards, playerState);
        }

这种写法会很方便,因为只需要写一个数字,不需要额外的其他引用。但做为长期项目来讲,可能这个方法的错误返回用了 351,另一个方法又是一个新的 event ,又可以使用 351。

然后就会出现,真的出了问题时,前端找服务端问: "报错 351 了,帮忙给看下什么问题"。

服务端代码全局一搜索,一堆 351,每个 351 都是不同的意思。完全不知道前端说的是哪个,定位问题也会费时间。

但如果让错误码分层,统一管理。就会很清晰明了。同一个错误码可以在多个方法中复用,但语义必须保持一致。

更方便统一管理,也可以让失败的原因跟清晰。

2. 当前错误码实现

game.constants.ts

据以上所述,因此,我按照错误来源将状态码分为几类:

ts 复制代码
export const RESULT_CODE = {
    SUCCESS: 200,
} as const;

export const PLAYER_STATE_CODE = {
    NOT_FOUND: 301, // Player state not found; cards may not have been dealt yet, or the connection state is abnormal.
} as const;

export const REQUEST_PARAM_CODE = {
    EMPTY_SELECTED_CARDS: 351, // The client did not select any cards.
    INVALID_CARD_FORMAT: 352, // Invalid card format from the client, for example: ZZ, 100H, ABC.
    CARD_NOT_IN_HAND: 353, // The selected card is not in the current player's hand.
    CARDS_LIMIT_EXCEEDED: 354, // The selected card count exceeds the limit, for example more than 5 cards.
    DUPLICATE_SELECTED_CARDS: 355, // The client submitted the same card multiple times, for example ["AH", "AH"].
    INVALID_ACTION: 356, // The action parameter is invalid, for example: "playy", "discardd", or empty.
} as const;

export const GAME_FLOW_CODE = {
    NO_PLAYS_LEFT: 401, // The current player has no plays left.
    NO_DISCARDS_LEFT: 402, // The current player has no discards left.
} as const;

......

八、当前实现的局限

到目前为止,我们已经完成了发牌、出牌、弃牌以及补牌的基本流程,玩家可以对手牌进行操作,游戏也具备了基础的交互能力。

从整体来看,这一套流程其实仍然是不完整的。

当前系统可以完成一轮操作。但缺少明确的"开始"和"结束"的定义。不知道怎么算赢,怎么算输。只能是根据出牌次数知道了,出5次牌本局就结束了,弃牌3次就不让弃牌了。但是然后呢?

  • 玩家是否达成目标
  • 本局是胜利还是失败
  • 本次操作带来了什么结果

这些对目前来讲都是未知的

这说明,当前实现仍然缺少一个核心部分:

👉 游戏机制(Game Mechanics

具体包括:记分规则、回合结算、胜负判定

一个完整的游戏,不仅需要操作流程,还需要明确的反馈机制,让玩家知道"做得如何"。

因此,再下一篇中,我们将重点补全得分计算与回合结算逻辑,让整个流程从"可操作"变为"可判定"。

九、本篇小结

到目前为止,本篇主要完成了从"发牌能力"到"手牌操作能力"的过渡。

1. 功能层面总结

从功能层面,我们实现了:

  • 出牌(play)与弃牌(discard)的基本逻辑
  • 自动补牌机制,保证手牌数量与游戏节奏
  • 基于服务端的完整状态维护(hand/deck/次数)
  • 多层参数校验,保证输入的安全性
  • 返回结构设计,使前端能够基于服务端状态进行渲染

2. 结构层面总结

在结构上,本篇也完成了一次比较关键的调整:

  • dealCardspoker 迁移到了 game, 明确了模块职责
  • 引入并迁移 GameState, 统一管理玩家状态
  • 将出牌与弃牌抽象为 selectCards, 减少重复逻辑
  • 对错误码进行分层设计,提升可维护性

整体来看,系统已经从"工具函数集合",逐步转变为"由服务端驱动的游戏状态系统"

3. 下一步会发生什么

在下一篇中,我们将重点补全以下内容:

  • 牌型判断结果接入出牌流程
  • 得分计算(Score System
  • 回合结算(Round Settlement
  • 游戏结束判定(Win / Lose

让整个系统从"玩家可以操作",进化为"玩家的操作可以被评估与反馈",从而形成一个完整的游戏闭环。

相关推荐
武子康3 分钟前
Java-07 深入浅出 MyBatis数据库一对多关系模型实战:表结构设计与查询实现
java·后端
花椒技术1 小时前
企业内部 Agent 落地复盘:Gateway、Skill 和二次确认如何串起受控业务执行
后端·agent·ai编程
我是一颗柠檬3 小时前
【MySQL全面教学】MySQL事务与ACID Day9(2026年)
数据库·后端·mysql
枕星而眠3 小时前
数据结构八大排序详解(一):四大简单排序
c语言·数据结构·c++·后端
IT_陈寒3 小时前
React useEffect闭包陷阱差点把我整失业了
前端·人工智能·后端
苍何3 小时前
爆肝两周,我把 Codex 最全实战指南开源了
后端
bug菌4 小时前
【SpringBoot 3.x 第254节】夯爆了,数据库访问性能优化实战详解!
数据库·spring boot·后端
Rust研习社4 小时前
从碎片化到标准化:cargo-bp 如何重构 Rust 开发逻辑
后端·rust·编程语言
锋行天下4 小时前
一句mysql复杂查询搞崩一个壮汉
后端·mysql·go
不肯过江东丶4 小时前
大聪明教你学Java | Spring AI Lab:一个让你 3 分钟接入 AI 对话能力的 Spring Boot 工具箱
spring boot·后端