从0到1实现Balatro游戏后端(6):Blind关卡状态设计与回合推进实现

从0到1实现Balatro游戏后端(6):Blind关卡状态设计与回合推进实现 本系列记录我从 0 到 1 实现一个 Balatro 风格的游戏后端系统并逐步将其工程化为可扩展的实时服务项目。balatro-realtime-backend - GitHub地址、GitCode地址 本文对应代码分支origin/feature/blind-level-system⚠️ 注意由于项目持续迭代当前仓库代码可能已发生变化本文内容基于该分支版本进行说明。✅ 本篇实现了什么本篇开始正式引入Blind关卡系统并让整个项目从“单局流程”逐渐进入“关卡生命周期”。当前阶段主要完成了Blind/Ante/Round状态设计GameState结构拆分BlindState生命周期引入Blind配置结构实现阶段目标分读取Blind结算与回合推进blindOver与gameOver生命周期拆分胜利保留进度失败重置游戏状态到这里为止整个项目已经开始具备small blind - big blind - boss blind - next ante这样的阶段推进能力。文章目录一、为什么要引入 Blind 系统二、GameState 状态结构重构1. 为什么开始重构 GameState2. Blind 系统带来的新状态问题3. GameState 的职责拆分3.1 playerState玩家资源状态3.2 blindState关卡进度状态4. GameState 不再是“巨型垃圾桶对象”5. 为什么现在就要做这次重构三、Blind 配置应该放在哪里1. Blind 配置到底属于 poker 还是 game2. 重新梳理 poker 与 game 的职责2.1 poker 的职责2.2 game 的职责2.3 职责总结3. 为什么 Blind 不属于 poker4. 最终的模块结构四、目标分数与 Blind 流转实现1. 从配置中读取当前 Blind1.1 blind.config.ts 实现1.2 getBlindConfig 代码实现2. 出牌后累计 currentBlindScore3. 判断当前 Blind 是否结束4. 通关后进入下一个 Blind4.1 SelectCardsResult 返回结构扩展5. 失败后清空当前游戏状态五、本篇总结1. 本篇完成了什么2. 当前仍缺少什么3. 下一篇会进入什么一、为什么要引入 Blind 系统Blind在传统的德州玩法中通常被称为‘盲注’。它最初的作用是为了推动牌局节奏持续进行避免玩家无限等待。而在Balatro这种以回合推进为核心的玩法里Blind的意义开始发生变化。它不再只是传统意义上的“小盲”和“大盲”而更像是一个个阶段性的关卡目标。玩家需要在有限的出牌次数内达到当前Blind的目标分数达到目标后进入下一个Blind失败则当前游戏结束。也正是因为如此整个游戏流程开始从单局结算逐渐变成small blind - big blind - boss blind - next ante这种阶段式推进结构。而这种小目标不断叠加的设计本身就是一种非常强的正反馈机制。因为玩家会明确知道当前这一关还差多少分下一个Blind是什么当前已经推进到了第几个ante想对比“无限局内循环”这种阶段推进会更容易让玩家产生我正在持续闯关的感觉。所以从这一阶段开始我开始正式引入Blind系统并让整个后端状态逐渐从单局流程升级为关卡生命周期。二、GameState 状态结构重构1. 为什么开始重构 GameState随着出牌、弃牌、补牌、得分结算逐渐完成GameState里的字段也越来越多。一开始这种写法还可以接受因为项目还停留在单局流程阶段手牌、牌堆、出牌次数、弃牌次数、当前得分、目标分、游戏状态。这些字段都直接放到GameState里短期看起来没有问题。但进入Blind系统之后问题开始变明显了。继续把所有字段都堆在GameState里它就会慢慢变成一个巨型垃圾桶对象。也就是什么都能放但很难看出每个字段到底属于哪一类状态。2. Blind 系统带来的新状态问题因为接下来还会增加当前round、当前ante、当前blindType、当前Blind目标分、当前Blind得分、Blind流转状态。从第二阶段开始游戏已经不再只是一局打完就结束而是开始进入多阶段的生命周期small blind - big blind - boss blind - next ante3. GameState 的职责拆分所以在加入Blind前我先对GameState做了一次结构拆分。拆分后的顶层结构如下exporttypeGameState{playerId:string;playerState:PlayerState;blindState:BlindState;gameStatus:playing|finished;};这里我把状态分为了两类。3.1 playerState玩家资源状态第一类是playerState它负责保存玩家当前持有的资源状态exporttypePlayerState{deck:Card[];// remain deckhand:string[];// current handplaysLeft:number;// Remaining card plays (default: 5)discardsLeft:number;// Remaining card abandonment times (default: 3)handSize:number;currentActionScore:number;};这些字段都和玩家当前这一局能怎么操作有关。比如玩家手里有哪些牌牌堆还剩多少牌还能出几次牌还能弃几次牌本次操作得了多少分3.2 blindState关卡进度状态第二类是blindState它负责保存当前关卡状态exporttypeBlindState{round:number;// Which move is it currentlyante:number;// The current ante for the round, which increases as the rounds progress.blindType:BlindType;// small | big | bosstargetScore:number;// The score that the player needs to reach to win, default is 300, can be customized for different difficulty levels.currentBlindScore:number;};这些字段描述的是当前游戏进度现在是第几轮现在是第几个ante当前是small blind、big blind还是boss blind当前Blind的目标分是多少当前Blind已经累计了多少分4. GameState 不再是“巨型垃圾桶对象”拆分后GameState不再直接承载所有具体字段而是变成一个顶层聚合状态GameState ├── playerState 玩家当前资源状态 ├── blindState 当前关卡进度状态 └── gameStatus 游戏整体状态5. 为什么现在就要做这次重构这次的重构目的不是为了看起来很高级而是为了后续Blind流转做准备。如果状态结构还是一锅大杂烩后面每加一个阶段都会让代码变得更难维护。所以这一步虽然还没有真正实现完整Blind流转但它让当前项目先具备了表达关卡状态的能力。三、Blind 配置应该放在哪里1. Blind 配置到底属于 poker 还是 game在开始实现Blind之前我先遇到了一个很小但很关键的问题Blind相关的配置到底应该放在poker里还是放在game里一开始会有点犹豫。因为当前项目里牌型判断和分数计算poker模块中完成而Blind又和目标分数、过关判断有关看起来好像也和牌局结果有关系。2. 重新梳理 poker 与 game 的职责但重新捋了一遍之后我发现这里其实要先区分两个模块的职责。2.1 poker 的职责poker更适合只负责“牌本身”的规则这几张牌是什么牌型这个牌型的基础分是多少这个牌型对应的倍率是多少哪些牌是真正参与计分的有效牌这些判断本质上都是围绕“牌面”展开的。2.2 game 的职责而game负责的是游戏流程游戏什么时候开始玩家当前是在出牌还是弃牌当前这一关的目标分是多少本次出牌后是否达到目标分没有达到目标分时游戏是否结束过关后下一个Blind应该是什么2.3 职责总结这样一拆边界就清晰了poker 牌面规则game 回合、关卡、玩家状态、结算流程3. 为什么 Blind 不属于 pokerBlind并不属于牌型判断能力而是游戏进度的一部分。它关心的不是“这几张牌是什么牌型”而是当前处在哪个Blind当前Blind的目标分是多少这一关是否已经通过通过后应该进入哪个Blind所以最终我没有把Blind配置放进poker模块而是放在了game模块下由game.service在结算流程中使用。4. 最终的模块结构当前结构如下src/game/ game.constants.ts game.types.ts game.service.ts game.gateway.ts blind.config.ts这里单独拆出blind.config.ts是因为Blind不是普通常量而是一组关卡配置。后续不管是调整目标分、增加Boss Blind还是扩展跳过Blind的奖励机制都可以优先从这份配置里开始处理。四、目标分数与 Blind 流转实现1. 从配置中读取当前 Blind本次Blind的相关配置是独立的.ts文件。里面的目标分数以及ante对应的配置目前都只是暂定的。因为当前项目还没有加入Joker、Modifier、特殊牌等额外加成系统所以整体分数成长还比较有限。因此这一阶段的重点并不是去精确调整数值。而是先把生命周期结构先跑通。Blind 配置 - Blind 获取 - Blind 结算 - Blind 推进1.1 blind.config.ts 实现exporttypeBlindTypesmall|big|boss;exportconstBLIND_SCORE_CONFIG{1:[{type:small,score:150},{type:big,score:250},{type:boss,score:350}],2:[{type:small,score:200},{type:big,score:300},{type:boss,score:400}],......}asconst;1.2 getBlindConfig 代码实现在Blind配置的获取以及校验中我单独抽了一个方法用于统一处理当前阶段的Blind数据这样做的目的是尽量避免round,ante,blindType,targetScore这些逻辑分散在多个位置计算同时也能让initGameState的初始化流程更干净。privategetBlindConfig(round:number){constanteMath.floor((round-1)/3)1;constblindTypeIndex(round-1)%3;constblindConfigBLIND_SCORE_CONFIG[ante];if(!blindConfig?.[blindTypeIndex]){thrownewError(Blind config not found for round:${round}, ante:${ante});}return{ante,blindType:blindConfig[blindTypeIndex].type,targetScore:blindConfig[blindTypeIndex].score,};}2. 出牌后累计 currentBlindScore在每次出牌后我们需要累积当前Blind的分数用于判断是否达到当前阶段的目标分。但要注意的是如果玩家是弃牌操作的话不能做分数的累加只有换牌的操作。所以在处理逻辑时需要区分当前是play还是discard。if(actionSELECT_CARD_ACTION.PLAY){blindState.currentBlindScorebaseScore*multiplier;playerState.currentActionScorebaseScore*multiplier;}else{playerState.currentActionScore0;}这里的currentActionScore表示当前这一次操作实际获得的分数。而currentBlindScore则表示当前整个Blind已经累计的阶段分数。3. 判断当前 Blind 是否结束在当前的阶段我们判断Blind是否结束只根据两点玩家是否已经耗尽所有出牌次数当前阶段是否已经达到目标分数任意一个条件满足都说明当前Blind生命周期已经结束。privateisBlindOver(playerState:PlayerState,gameState:GameState):boolean{returnplayerState.playsLeft0||gameState.blindState.currentBlindScoregameState.blindState.targetScore;}4. 通关后进入下一个 Blind在判断当前Blind是否结束时需要额外再判断是当前达成了目标进入了下一阶段还是游戏彻底的结束。这两种是不同的生命周期。因为在第一阶段时整个项目还是单局流程所以之前只需要gameOver一个状态即可。但进入Blind回合制之后当前阶段结束 ! 整场游戏结束。所以这里新增了blindOver用于表示当前 Blind 是否已经结算完成。而gameOver则用于表示当前整场游戏是否已经失败结束。4.1 SelectCardsResult 返回结构扩展为了支持Blind生命周期的推进SelectCardsResult也增加了部分阶段状态返回exporttypeSelectCardsResult{...blindOver?:boolean;round?:number;ante?:number;};这样前端在收到结算数据时就能够知道当前阶段是否已经结束当前已经推进到哪个round当前处于哪个ante截至目前项目已经具备了Blind配置阶段目标分Blind结算回合推进失败重置写到这里的时候我突然意识到一件事Blind的加入看起来只是多了一套配置但实际上它已经开始改变整个项目的状态设计方式。第一阶段时状态更多是在描述玩家当前能做什么。而进入Blind之后状态开始描述游戏当前进行到了哪里。看起来只是多了几个字段但项目关注点其实已经发生了变化。整个游戏开始真正进入关卡生命周期阶段。5. 失败后清空当前游戏状态因为回合制的延续所以游戏的初始状态不再只是新开局还有了本局胜利后下一局的延续。所以gameState的数据在本局游戏结束后不再只是单纯的“清空然后重新开始”。因为Blind的加入让游戏开始出现了两种不同的生命周期当前阶段的结束整场游戏的结束如果玩家成功达到当前Blind的目标分数那么并不会直接删除当前gameState而是保留部分关卡状态并在下一次startGame时继续推进small blind - big blind - boss blind - next ante但如果玩家在当前阶段内没有达到目标分数并且已经耗尽了所有的出牌次数那么当前游戏才会真正的结束。此时会清空当前玩家的运行时状态delete this.gameStates[playerId];这样做的目的是为了让通关后的状态能够延续失败后的状态重新开始也就是胜利保留阶段进度失败重置当前游戏这才是真正意义上的回合生命周期的成立。而这也是当前项目第一次开始从单局流程真正进入到了关卡推进的阶段。五、本篇总结1. 本篇完成了什么这一阶段项目终于开始从单局流程真正进入关卡生命周期之前的实现更多还是围绕发牌出牌弃牌补牌算分这些“单局内部逻辑”展开。而Blind系统加入之后游戏开始第一次出现当前阶段阶段目标回合推进生命周期延续这些更接近真实游戏流程的概念。本篇中我主要完成了Blind关卡状态设计GameState结构拆分BlindState生命周期引入Blind配置结构实现阶段目标分读取Blind结算与回合推进胜利保留进度失败重置状态整个项目也开始从这一局发生了什么逐渐转向游戏当前推进到了哪里2. 当前仍缺少什么虽然当前还没有加入Boss Blind特殊规则奖励机制Modifier商店系统这些真正让Balatro开始变复杂的内容。但至少现在整个后端已经开始真正具备关卡推进的能力了。3. 下一篇会进入什么而下一阶段我会开始继续实现Boss Blind特殊阶段规则更复杂的生命周期流转让整个游戏真正开始出现“不同关卡有不同玩法”的变化。不过真正玩过Balatro的人可能都知道Boss Blind最有意思的地方从来不是分数而是规则。当某些牌型失效、某些操作被限制之后玩家原本习惯的出牌方式可能会被彻底打乱。而对于后端来说这意味着游戏规则开始不再固定。下一篇就准备来解决这个问题。