从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维持手牌数量将dealCards从poker迁移至game明确模块职责引入并迁移GameState由服务端统一维护玩家状态增加多层参数校验保证输入安全与状态一致性设计返回结构使前端基于服务端状态进行渲染实现错误码分层提升错误定位与可维护性 本篇的核心变化是让游戏从“功能调用”升级为“状态驱动”。文章目录一、为什么要实现出牌 / 弃牌 / 补牌二、为什么 dealCards 要从 poker 移到 game1. dealCards的迁移2. GameState是否同步到/game下三、统一抽象 selectCards出牌与弃牌的公共流程1. 公共流程说明2. 核心实现selectCards四、参数校验为什么不能相信前端1. 参数校验说明2. 代码结构实现五、自动补牌从操作到状态更新1. 补牌流程说明2. 核心实现removeAndDrawCards六、返回结果设计1. 返回结果说明1状态数据hand / playsLeft 等2操作信息本次行为的上下文3流程控制游戏状态判定2. 核心实现PlayCardsResult七、错误码分层让失败原因更清晰1. 分层设计说明2. 当前错误码实现八、当前实现的局限九、本篇小结1. 功能层面总结2. 结构层面总结3. 下一步会发生什么一、为什么要实现出牌 / 弃牌 / 补牌在前面的几篇中我们已经完成了牌型判断、洗牌以及发牌流程的实现。虽然当前的功能已经可以独立运行但整体仍然停留在“模块能力”的阶段而不是一个完整的游戏流程。从游戏设计的角度来看Balatro本质是一个回合制游戏。在一轮完整的回合中玩家不仅需要看到手牌还需要对手牌进行操作并基于这些操作持续参与游戏。然后在当前实现中系统只具备发牌能力玩家无法选择出牌调整手牌持续参与回合流程整个流程更像是发完一手牌后结束而不是一个可以交互和推进的游戏过程。因此我们需要引入三类能力玩家操作支持出牌和弃牌状态维护在操作后自动补牌保持手牌数量节奏控制让玩家能够持续参与回合而不是一次性结束 基于以上需求本篇将实现出牌、弃牌以及补牌的核心逻辑为后续回合结算与得分系统打下基础。二、为什么 dealCards 要从 poker 移到 game1. 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. 核心实现selectCardsselectCards(selectedCards:string[],action:play|discard,playerId:string):PlayCardsResult{// 校验// remove draw// 扣次数// 返回结果}四、参数校验为什么不能相信前端1. 参数校验说明在本次selectCards的实现中我增加了多层参数校验逻辑。从代码层面来看这些校验主要包括参数是否存在选择的牌是否为空是否为合法牌格式是否真实存在于当前手牌中是否存在重复选择是否超过数量限制当前是否还有可用操作次数从视觉上看这部分代码主要由一系列if-else组成可能会显得较为冗长。但实际上这些逻辑并不是冗余而是服务端必须具备的防御性校验。在服务端设计中一个基本的原则是不能信任来自前端的任何输入。因为所有参数都可能被篡改或构造如果不在进入核心逻辑之前进行完整的校验就可能导致状态异常甚至逻辑错误。因此这些校验的目的不是避免写代码而是将所有潜在问题提前拦截在入口阶段保证后续流程的稳定性。2. 代码结构实现以下为核心校验逻辑的结构示例省略具体实现// 是否选择了牌if(!selectedCards?.length){...}// 是否超过最大选择数量if(selectedCards?.lengthGAME_RULE.MAX_SELECT_CARDS){...}// 是否为合法牌格式如 AH, 10Dif(!CARD_PATTERN.test(card)){...}// 是否存在重复选择if(selectedSet.size!selectedCards.length){...}// 是否真实存在于当前手牌中if(!existCards){...}// 当前是否还有可操作次数if(playerState.playsLeft0){...}...五、自动补牌从操作到状态更新1. 补牌流程说明但玩家出牌或者弃牌结束后我们需要根据对应牌数补全用户手牌维持手牌数量保证回合可持续进行。在本次实现中我们主要是做的从用户手中移除用户所选的牌从牌堆再次splice牌数补到用户的手牌中(这里使用splice是为了直接修改服务端维护的牌堆状态保证牌不会被重复抽取)也就是说从出牌/弃牌到补牌并不是独立的这是相辅相成的状态流一次完整的状态更新。这样处理后前端拿到的不是操作结果的提示而是服务器在处理完流程后返回的用户手牌。因此removeAndDrawCards的作用并不是简单的返回几张新牌而是完成一次完整的手牌状态流转移除已选择的牌从服务端牌堆中补牌更新玩家当前手牌返回补牌后的最新状态这一步完成后出牌和弃牌才真正从“接口调用”变成了“游戏状态”。2. 核心实现removeAndDrawCardsprivateremoveAndDrawCards(selectedCards:string[],handCards:string[],playerState:GameState):string[]{constnewHand:string[][];constdeck:Card[]playerState.deck;// 移除选中的手牌for(leti0;ihandCards.length;i){if(!selectedCards.includes(handCards[i])){newHand.push(handCards[i]);}}// 计算需要补牌的数量constgetSize:numberplayerState.handSize-newHand.length;// 从牌堆中抽牌splice 会直接修改 deckconstgetDeck:string[]this.pokerService.serializeCards(deck.splice(0,getSize));//合并新手牌newHand.push(...getDeck);returnnewHand;}六、返回结果设计1. 返回结果说明在服务端设计中一个基本原则是前端依赖服务器端的数据做为唯一的状态来源。也就是说前端在对外展示的时候所依赖的数据应尽量由服务端返回而不是自行维护。因此在设计返回结果时可以将数据分为三类1状态数据hand / playsLeft 等这些数据会随着出牌、弃牌行为发生变化是前端渲染的核心依据例如剩余出牌次数playsLeft剩余弃牌次数discardsLeft当前手牌hand牌堆剩余数量remainingDeckCount这些数据必须在每次操作后由服务端返回以保证前端状态的一致性。2操作信息本次行为的上下文除了状态数据之外还可以返回本次操作相关的信息例如本次选择的牌selectedCards这类数据不会影响游戏状态但可以帮助前端进行动画展示或操作反馈而不需要额外维护临时数据。3流程控制游戏状态判定关于游戏是否结束gameOver的判定也应由服务端统一控制。游戏的结束条件例如是否达成目标、是否耗尽操作次数都属于游戏规则的一部分因此应在服务端进行判断并在操作完成后返回结果。2. 核心实现PlayCardsResultexporttypePlayCardsResult{code:number;// 操作结果状态码hand:string[];// 用户操作后新手牌是什么playsLeft:number;// 出牌剩余次数discardsLeft:number;// 弃牌剩余次数remainingDeckCount:number;// 牌堆剩余的数量selectedCards:string[];// 用户选择操作的牌有哪些gameOver:boolean;// 游戏是否结束};七、错误码分层让失败原因更清晰1. 分层设计说明错误代码分层是让代码更清晰。如果是用数字直接表示会是什么样子的比如用户没有选择牌前端让参数selectedCards为空那么在参数校验的时候我们就会进行拦截返回。如果直接在业务代码中写数字短期看起来很方便if(!selectedCards?.length){returnthis.buildSelectCardsResult(351,selectedCards,playerState);}这种写法会很方便因为只需要写一个数字不需要额外的其他引用。但做为长期项目来讲可能这个方法的错误返回用了 351另一个方法又是一个新的event,又可以使用 351。然后就会出现真的出了问题时前端找服务端问: “报错 351 了帮忙给看下什么问题”。服务端代码全局一搜索一堆 351每个 351 都是不同的意思。完全不知道前端说的是哪个定位问题也会费时间。但如果让错误码分层统一管理。就会很清晰明了。同一个错误码可以在多个方法中复用但语义必须保持一致。更方便统一管理也可以让失败的原因跟清晰。2. 当前错误码实现game.constants.ts据以上所述因此我按照错误来源将状态码分为几类exportconstRESULT_CODE{SUCCESS:200,}asconst;exportconstPLAYER_STATE_CODE{NOT_FOUND:301,// Player state not found; cards may not have been dealt yet, or the connection state is abnormal.}asconst;exportconstREQUEST_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 players 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.}asconst;exportconstGAME_FLOW_CODE{NO_PLAYS_LEFT:401,// The current player has no plays left.NO_DISCARDS_LEFT:402,// The current player has no discards left.}asconst;......八、当前实现的局限到目前为止我们已经完成了发牌、出牌、弃牌以及补牌的基本流程玩家可以对手牌进行操作游戏也具备了基础的交互能力。从整体来看这一套流程其实仍然是不完整的。当前系统可以完成一轮操作。但缺少明确的“开始”和“结束”的定义。不知道怎么算赢怎么算输。只能是根据出牌次数知道了出5次牌本局就结束了弃牌3次就不让弃牌了。但是然后呢玩家是否达成目标本局是胜利还是失败本次操作带来了什么结果这些对目前来讲都是未知的这说明当前实现仍然缺少一个核心部分 游戏机制Game Mechanics具体包括记分规则、回合结算、胜负判定一个完整的游戏不仅需要操作流程还需要明确的反馈机制让玩家知道“做得如何”。因此再下一篇中我们将重点补全得分计算与回合结算逻辑让整个流程从“可操作”变为“可判定”。九、本篇小结到目前为止本篇主要完成了从“发牌能力”到“手牌操作能力”的过渡。1. 功能层面总结从功能层面我们实现了出牌(play)与弃牌(discard)的基本逻辑自动补牌机制保证手牌数量与游戏节奏基于服务端的完整状态维护(hand/deck/次数)多层参数校验保证输入的安全性返回结构设计使前端能够基于服务端状态进行渲染2. 结构层面总结在结构上本篇也完成了一次比较关键的调整将dealCards从poker迁移到了game, 明确了模块职责引入并迁移GameState, 统一管理玩家状态将出牌与弃牌抽象为selectCards, 减少重复逻辑对错误码进行分层设计提升可维护性整体来看系统已经从“工具函数集合”逐步转变为“由服务端驱动的游戏状态系统”3. 下一步会发生什么在下一篇中我们将重点补全以下内容牌型判断结果接入出牌流程得分计算Score System回合结算Round Settlement游戏结束判定Win/Lose让整个系统从“玩家可以操作”进化为“玩家的操作可以被评估与反馈”从而形成一个完整的游戏闭环。