- 本系列记录:从 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 版本进行说明。
- 📌 本文对应代码版本:commit:
✅本篇实现了什么
本篇完成了玩家手牌操作能力的构建,使系统从"只能发牌"演进为"可交互的游戏流程":
- 实现出牌(
play)与弃牌(discard)逻辑 - 引入
selectCards抽象,统一处理手牌操作流程 - 实现自动补牌机制(
remove + draw),维持手牌数量 - 将
dealCards从poker迁移至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. 返回结果说明)
-
- [(1)状态数据(hand / playsLeft 等)](#(1)状态数据(hand / playsLeft 等))
- (2)操作信息:本次行为的上下文
- (3)流程控制:游戏状态判定
- [2. 核心实现:PlayCardsResult](#2. 核心实现:PlayCardsResult)
- 七、错误码分层:让失败原因更清晰
-
- [1. 分层设计说明](#1. 分层设计说明)
- [2. 当前错误码实现](#2. 当前错误码实现)
- 八、当前实现的局限
- 九、本篇小结
-
- [1. 功能层面总结](#1. 功能层面总结)
- [2. 结构层面总结](#2. 结构层面总结)
- [3. 下一步会发生什么](#3. 下一步会发生什么)
一、为什么要实现出牌 / 弃牌 / 补牌
在前面的几篇中,我们已经完成了牌型判断、洗牌以及发牌流程的实现。虽然当前的功能已经可以独立运行,但整体仍然停留在"模块能力"的阶段,而不是一个完整的游戏流程。
从游戏设计的角度来看,Balatro 本质是一个回合制游戏。在一轮完整的回合中,玩家不仅需要看到手牌,还需要对手牌进行操作,并基于这些操作持续参与游戏。
然后,在当前实现中,系统只具备发牌能力,玩家无法:
- 选择出牌
- 调整手牌
- 持续参与回合流程
整个流程更像是发完一手牌后结束,而不是一个可以交互和推进的游戏过程。
因此,我们需要引入三类能力:
- 玩家操作:支持出牌和弃牌
- 状态维护:在操作后自动补牌,保持手牌数量
- 节奏控制:让玩家能够持续参与回合,而不是一次性结束
👉 基于以上需求,本篇将实现出牌、弃牌以及补牌的核心逻辑,为后续回合结算与得分系统打下基础。
二、为什么 dealCards 要从 poker 移到 game
1. dealCards的迁移
上一篇我们把 dealCards 暂时归到了 poker,当时考虑的是:发牌的本质是对牌堆的裁剪与分发,属于牌操作的一部分。
但在实现本篇功能的过程中,我逐渐发现 dealCards 实际更像是游戏开始的初始化操作,依赖的是玩家状态(hand、deck)以及游戏流程控制。
例如:
- 初始化玩家手牌(
hand) - 维护剩余牌堆(
deck) - 控制出牌/弃牌次数
这说明,dealCards 已经不再是一个纯粹的扑克牌操作,而是一个与游戏流程强相关的行为。
因此,我将 dealCards 从 poker 层迁移到了 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 模块。
其实写到这里的时候,我也开始逐渐意识到:项目刚开始的时候,模块边界问题并不会特别明显。
但随着:
- 玩家状态
- 出牌流程
- 回合控制
- 后续的得分、
Blind、Modifier
这些东西不断增加后,"这个逻辑到底该放哪层",会越来越重要。包括:
dealCards是否应该放在gameGameState是否应该归属于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. 结构层面总结
在结构上,本篇也完成了一次比较关键的调整:
- 将
dealCards从poker迁移到了game, 明确了模块职责 - 引入并迁移
GameState, 统一管理玩家状态 - 将出牌与弃牌抽象为
selectCards, 减少重复逻辑 - 对错误码进行分层设计,提升可维护性
整体来看,系统已经从"工具函数集合",逐步转变为"由服务端驱动的游戏状态系统"
3. 下一步会发生什么
在下一篇中,我们将重点补全以下内容:
- 牌型判断结果接入出牌流程
- 得分计算(
Score System) - 回合结算(
Round Settlement) - 游戏结束判定(
Win/Lose)
让整个系统从"玩家可以操作",进化为"玩家的操作可以被评估与反馈",从而形成一个完整的游戏闭环。