本系列记录我从 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 这样的阶段推进能力。
#mermaid-svg-HpukHAEitFlTNfLs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HpukHAEitFlTNfLs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HpukHAEitFlTNfLs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HpukHAEitFlTNfLs .error-icon{fill:#552222;}#mermaid-svg-HpukHAEitFlTNfLs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HpukHAEitFlTNfLs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HpukHAEitFlTNfLs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HpukHAEitFlTNfLs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HpukHAEitFlTNfLs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HpukHAEitFlTNfLs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HpukHAEitFlTNfLs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HpukHAEitFlTNfLs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HpukHAEitFlTNfLs .marker.cross{stroke:#333333;}#mermaid-svg-HpukHAEitFlTNfLs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HpukHAEitFlTNfLs p{margin:0;}#mermaid-svg-HpukHAEitFlTNfLs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HpukHAEitFlTNfLs .cluster-label text{fill:#333;}#mermaid-svg-HpukHAEitFlTNfLs .cluster-label span{color:#333;}#mermaid-svg-HpukHAEitFlTNfLs .cluster-label span p{background-color:transparent;}#mermaid-svg-HpukHAEitFlTNfLs .label text,#mermaid-svg-HpukHAEitFlTNfLs span{fill:#333;color:#333;}#mermaid-svg-HpukHAEitFlTNfLs .node rect,#mermaid-svg-HpukHAEitFlTNfLs .node circle,#mermaid-svg-HpukHAEitFlTNfLs .node ellipse,#mermaid-svg-HpukHAEitFlTNfLs .node polygon,#mermaid-svg-HpukHAEitFlTNfLs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HpukHAEitFlTNfLs .rough-node .label text,#mermaid-svg-HpukHAEitFlTNfLs .node .label text,#mermaid-svg-HpukHAEitFlTNfLs .image-shape .label,#mermaid-svg-HpukHAEitFlTNfLs .icon-shape .label{text-anchor:middle;}#mermaid-svg-HpukHAEitFlTNfLs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HpukHAEitFlTNfLs .rough-node .label,#mermaid-svg-HpukHAEitFlTNfLs .node .label,#mermaid-svg-HpukHAEitFlTNfLs .image-shape .label,#mermaid-svg-HpukHAEitFlTNfLs .icon-shape .label{text-align:center;}#mermaid-svg-HpukHAEitFlTNfLs .node.clickable{cursor:pointer;}#mermaid-svg-HpukHAEitFlTNfLs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HpukHAEitFlTNfLs .arrowheadPath{fill:#333333;}#mermaid-svg-HpukHAEitFlTNfLs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HpukHAEitFlTNfLs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HpukHAEitFlTNfLs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HpukHAEitFlTNfLs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HpukHAEitFlTNfLs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HpukHAEitFlTNfLs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HpukHAEitFlTNfLs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HpukHAEitFlTNfLs .cluster text{fill:#333;}#mermaid-svg-HpukHAEitFlTNfLs .cluster span{color:#333;}#mermaid-svg-HpukHAEitFlTNfLs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HpukHAEitFlTNfLs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HpukHAEitFlTNfLs rect.text{fill:none;stroke-width:0;}#mermaid-svg-HpukHAEitFlTNfLs .icon-shape,#mermaid-svg-HpukHAEitFlTNfLs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HpukHAEitFlTNfLs .icon-shape p,#mermaid-svg-HpukHAEitFlTNfLs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HpukHAEitFlTNfLs .icon-shape .label rect,#mermaid-svg-HpukHAEitFlTNfLs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HpukHAEitFlTNfLs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HpukHAEitFlTNfLs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HpukHAEitFlTNfLs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Ante 3
Ante 2
Ante 1
Small Blind
Big Blind
Boss Blind
Small Blind
Big Blind
Boss Blind
Small Blind
Big Blind
Boss Blind
文章目录
- [一、为什么要引入 Blind 系统](#一、为什么要引入 Blind 系统)
- [二、GameState 状态结构重构](#二、GameState 状态结构重构)
-
- [1. 为什么开始重构 GameState?](#1. 为什么开始重构 GameState?)
- [2. Blind 系统带来的新状态问题](#2. Blind 系统带来的新状态问题)
- [3. GameState 的职责拆分](#3. GameState 的职责拆分)
-
- [3.1 playerState:玩家资源状态](#3.1 playerState:玩家资源状态)
- [3.2 blindState:关卡进度状态](#3.2 blindState:关卡进度状态)
- [4. GameState 不再是"巨型垃圾桶对象"](#4. GameState 不再是“巨型垃圾桶对象”)
- [5. 为什么现在就要做这次重构?](#5. 为什么现在就要做这次重构?)
- [三、Blind 配置应该放在哪里?](#三、Blind 配置应该放在哪里?)
-
- [1. Blind 配置到底属于 poker 还是 game?](#1. Blind 配置到底属于 poker 还是 game?)
- [2. 重新梳理 poker 与 game 的职责](#2. 重新梳理 poker 与 game 的职责)
-
- [2.1 poker 的职责](#2.1 poker 的职责)
- [2.2 game 的职责](#2.2 game 的职责)
- [2.3 职责总结](#2.3 职责总结)
- [3. 为什么 Blind 不属于 poker?](#3. 为什么 Blind 不属于 poker?)
- [4. 最终的模块结构](#4. 最终的模块结构)
- [四、目标分数与 Blind 流转实现](#四、目标分数与 Blind 流转实现)
-
- [1. 从配置中读取当前 Blind](#1. 从配置中读取当前 Blind)
-
- [1.1 blind.config.ts 实现](#1.1 blind.config.ts 实现)
- [1.2 getBlindConfig 代码实现](#1.2 getBlindConfig 代码实现)
- [2. 出牌后累计 currentBlindScore](#2. 出牌后累计 currentBlindScore)
- [3. 判断当前 Blind 是否结束](#3. 判断当前 Blind 是否结束)
- [4. 通关后进入下一个 Blind](#4. 通关后进入下一个 Blind)
-
- [4.1 SelectCardsResult 返回结构扩展](#4.1 SelectCardsResult 返回结构扩展)
- [5. 失败后清空当前游戏状态](#5. 失败后清空当前游戏状态)
- 五、本篇总结
-
- [1. 本篇完成了什么](#1. 本篇完成了什么)
- [2. 当前仍缺少什么](#2. 当前仍缺少什么)
- [3. 下一篇会进入什么](#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 ante
3. GameState 的职责拆分
所以在加入 Blind 前,我先对 GameState 做了一次结构拆分。拆分后的顶层结构如下:
ts
export type GameState = {
playerId: string;
playerState: PlayerState;
blindState: BlindState;
gameStatus: "playing" | "finished";
};
这里我把状态分为了两类。
3.1 playerState:玩家资源状态
第一类是 playerState,它负责保存玩家当前持有的资源状态:
ts
export type PlayerState = {
deck: Card[]; // remain deck
hand: string[]; // current hand
playsLeft: number; // Remaining card plays (default: 5)
discardsLeft: number; // Remaining card abandonment times (default: 3)
handSize: number;
currentActionScore: number;
};
这些字段都和玩家当前这一局能怎么操作有关。比如:
- 玩家手里有哪些牌?
- 牌堆还剩多少牌?
- 还能出几次牌?
- 还能弃几次牌?
- 本次操作得了多少分?
3.2 blindState:关卡进度状态
第二类是 blindState,它负责保存当前关卡状态:
ts
export type BlindState = {
round: number; // Which move is it currently
ante: number; // The current ante for the round, which increases as the rounds progress.
blindType: BlindType; // "small" | "big" | "boss"
targetScore: 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 不再直接承载所有具体字段,而是变成一个顶层聚合状态:
#mermaid-svg-60xbc8Vhx3XjYcjA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-60xbc8Vhx3XjYcjA .error-icon{fill:#552222;}#mermaid-svg-60xbc8Vhx3XjYcjA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-60xbc8Vhx3XjYcjA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-60xbc8Vhx3XjYcjA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-60xbc8Vhx3XjYcjA .marker.cross{stroke:#333333;}#mermaid-svg-60xbc8Vhx3XjYcjA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-60xbc8Vhx3XjYcjA p{margin:0;}#mermaid-svg-60xbc8Vhx3XjYcjA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA .cluster-label text{fill:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA .cluster-label span{color:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA .cluster-label span p{background-color:transparent;}#mermaid-svg-60xbc8Vhx3XjYcjA .label text,#mermaid-svg-60xbc8Vhx3XjYcjA span{fill:#333;color:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA .node rect,#mermaid-svg-60xbc8Vhx3XjYcjA .node circle,#mermaid-svg-60xbc8Vhx3XjYcjA .node ellipse,#mermaid-svg-60xbc8Vhx3XjYcjA .node polygon,#mermaid-svg-60xbc8Vhx3XjYcjA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-60xbc8Vhx3XjYcjA .rough-node .label text,#mermaid-svg-60xbc8Vhx3XjYcjA .node .label text,#mermaid-svg-60xbc8Vhx3XjYcjA .image-shape .label,#mermaid-svg-60xbc8Vhx3XjYcjA .icon-shape .label{text-anchor:middle;}#mermaid-svg-60xbc8Vhx3XjYcjA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-60xbc8Vhx3XjYcjA .rough-node .label,#mermaid-svg-60xbc8Vhx3XjYcjA .node .label,#mermaid-svg-60xbc8Vhx3XjYcjA .image-shape .label,#mermaid-svg-60xbc8Vhx3XjYcjA .icon-shape .label{text-align:center;}#mermaid-svg-60xbc8Vhx3XjYcjA .node.clickable{cursor:pointer;}#mermaid-svg-60xbc8Vhx3XjYcjA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-60xbc8Vhx3XjYcjA .arrowheadPath{fill:#333333;}#mermaid-svg-60xbc8Vhx3XjYcjA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-60xbc8Vhx3XjYcjA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-60xbc8Vhx3XjYcjA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-60xbc8Vhx3XjYcjA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-60xbc8Vhx3XjYcjA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-60xbc8Vhx3XjYcjA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-60xbc8Vhx3XjYcjA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-60xbc8Vhx3XjYcjA .cluster text{fill:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA .cluster span{color:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-60xbc8Vhx3XjYcjA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-60xbc8Vhx3XjYcjA rect.text{fill:none;stroke-width:0;}#mermaid-svg-60xbc8Vhx3XjYcjA .icon-shape,#mermaid-svg-60xbc8Vhx3XjYcjA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-60xbc8Vhx3XjYcjA .icon-shape p,#mermaid-svg-60xbc8Vhx3XjYcjA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-60xbc8Vhx3XjYcjA .icon-shape .label rect,#mermaid-svg-60xbc8Vhx3XjYcjA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-60xbc8Vhx3XjYcjA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-60xbc8Vhx3XjYcjA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-60xbc8Vhx3XjYcjA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重构前
GameState
deck
hand
playsLeft
discardsLeft
round
ante
blindType
targetScore
currentBlindScore
gameStatus
重构后
GameState
PlayerState
BlindState
gameStatus
deck
hand
playsLeft
discardsLeft
handSize
currentActionScore
round
ante
blindType
targetScore
currentBlindScore
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 不属于 poker?
Blind 并不属于牌型判断能力,而是游戏进度的一部分。
它关心的不是"这几张牌是什么牌型",而是:
- 当前处在哪个
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 实现
ts
export type BlindType = "small" | "big" | "boss";
export const BLIND_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 }],
......
} as const;
1.2 getBlindConfig 代码实现
在 Blind 配置的获取以及校验中,我单独抽了一个方法,用于统一处理当前阶段的 Blind 数据,这样做的目的,是尽量避免 round,ante,blindType,targetScore 这些逻辑分散在多个位置计算,同时也能让 initGameState 的初始化流程更干净。
ts
private getBlindConfig(round: number) {
const ante = Math.floor((round - 1) / 3) + 1;
const blindTypeIndex = (round - 1) % 3;
const blindConfig = BLIND_SCORE_CONFIG[ante];
if (!blindConfig?.[blindTypeIndex]) {
throw new Error(`Blind config not found for round: ${round}, ante: ${ante}`);
}
return {
ante,
blindType: blindConfig[blindTypeIndex].type,
targetScore: blindConfig[blindTypeIndex].score,
};
}
2. 出牌后累计 currentBlindScore
在每次出牌后,我们需要累积当前 Blind 的分数,用于判断是否达到当前阶段的目标分。
但要注意的是,如果玩家是弃牌操作的话,不能做分数的累加,只有换牌的操作。
所以在处理逻辑时,需要区分:当前是 play,还是 discard。
ts
if (action == SELECT_CARD_ACTION.PLAY) {
blindState.currentBlindScore += baseScore * multiplier;
playerState.currentActionScore = baseScore * multiplier;
} else {
playerState.currentActionScore = 0;
}
这里的 currentActionScore 表示当前这一次操作实际获得的分数。而 currentBlindScore 则表示当前整个 Blind 已经累计的阶段分数。
#mermaid-svg-nWAGwg8pFbi2jSWO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nWAGwg8pFbi2jSWO .error-icon{fill:#552222;}#mermaid-svg-nWAGwg8pFbi2jSWO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nWAGwg8pFbi2jSWO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nWAGwg8pFbi2jSWO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nWAGwg8pFbi2jSWO .marker.cross{stroke:#333333;}#mermaid-svg-nWAGwg8pFbi2jSWO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nWAGwg8pFbi2jSWO p{margin:0;}#mermaid-svg-nWAGwg8pFbi2jSWO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO .cluster-label text{fill:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO .cluster-label span{color:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO .cluster-label span p{background-color:transparent;}#mermaid-svg-nWAGwg8pFbi2jSWO .label text,#mermaid-svg-nWAGwg8pFbi2jSWO span{fill:#333;color:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO .node rect,#mermaid-svg-nWAGwg8pFbi2jSWO .node circle,#mermaid-svg-nWAGwg8pFbi2jSWO .node ellipse,#mermaid-svg-nWAGwg8pFbi2jSWO .node polygon,#mermaid-svg-nWAGwg8pFbi2jSWO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nWAGwg8pFbi2jSWO .rough-node .label text,#mermaid-svg-nWAGwg8pFbi2jSWO .node .label text,#mermaid-svg-nWAGwg8pFbi2jSWO .image-shape .label,#mermaid-svg-nWAGwg8pFbi2jSWO .icon-shape .label{text-anchor:middle;}#mermaid-svg-nWAGwg8pFbi2jSWO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nWAGwg8pFbi2jSWO .rough-node .label,#mermaid-svg-nWAGwg8pFbi2jSWO .node .label,#mermaid-svg-nWAGwg8pFbi2jSWO .image-shape .label,#mermaid-svg-nWAGwg8pFbi2jSWO .icon-shape .label{text-align:center;}#mermaid-svg-nWAGwg8pFbi2jSWO .node.clickable{cursor:pointer;}#mermaid-svg-nWAGwg8pFbi2jSWO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nWAGwg8pFbi2jSWO .arrowheadPath{fill:#333333;}#mermaid-svg-nWAGwg8pFbi2jSWO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nWAGwg8pFbi2jSWO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nWAGwg8pFbi2jSWO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nWAGwg8pFbi2jSWO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nWAGwg8pFbi2jSWO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nWAGwg8pFbi2jSWO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nWAGwg8pFbi2jSWO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nWAGwg8pFbi2jSWO .cluster text{fill:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO .cluster span{color:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nWAGwg8pFbi2jSWO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nWAGwg8pFbi2jSWO rect.text{fill:none;stroke-width:0;}#mermaid-svg-nWAGwg8pFbi2jSWO .icon-shape,#mermaid-svg-nWAGwg8pFbi2jSWO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nWAGwg8pFbi2jSWO .icon-shape p,#mermaid-svg-nWAGwg8pFbi2jSWO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nWAGwg8pFbi2jSWO .icon-shape .label rect,#mermaid-svg-nWAGwg8pFbi2jSWO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nWAGwg8pFbi2jSWO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nWAGwg8pFbi2jSWO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nWAGwg8pFbi2jSWO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否 discard
是
否
是
否
玩家选择操作
action 是 play?
计算本次得分
currentBlindScore 累加
达到 targetScore?
换牌
currentActionScore = 0
继续当前 Blind
blindOver = true
保留阶段进度
下一次 startGame
round + 1
进入下一个 Blind
playsLeft <= 0?
gameOver = true
delete gameState
失败后重新开始
3. 判断当前 Blind 是否结束
在当前的阶段,我们判断 Blind 是否结束,只根据两点
- 玩家是否已经耗尽所有出牌次数
- 当前阶段是否已经达到目标分数
任意一个条件满足,都说明当前 Blind 生命周期已经结束。
ts
private isBlindOver(playerState: PlayerState, gameState: GameState): boolean {
return playerState.playsLeft <= 0 ||
gameState.blindState.currentBlindScore >= gameState.blindState.targetScore;
}
4. 通关后进入下一个 Blind
在判断当前 Blind 是否结束时,需要额外再判断,是当前达成了目标进入了下一阶段,还是游戏彻底的结束。这两种是不同的生命周期。
因为在第一阶段时,整个项目还是单局流程,所以之前只需要:gameOver 一个状态即可。
但进入 Blind 回合制之后:当前阶段结束 != 整场游戏结束。
所以这里新增了:blindOver 用于表示:当前 Blind 是否已经结算完成。而 gameOver 则用于表示:当前整场游戏是否已经失败结束。
4.1 SelectCardsResult 返回结构扩展
为了支持 Blind 生命周期的推进,SelectCardsResult 也增加了部分阶段状态返回:
ts
export type SelectCardsResult = {
...
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 最有意思的地方,从来不是分数,而是规则。
当某些牌型失效、某些操作被限制之后,玩家原本习惯的出牌方式可能会被彻底打乱。
而对于后端来说,这意味着游戏规则开始不再固定。
下一篇就准备来解决这个问题。