这篇文章开始一个新的长期系列:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。
- balatro-realtime-backend -> GitHub地址、GitCode地址
- 📌 本文对应代码分支:
origin/feature/boss-blind - ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。
- 📌 本文对应代码分支:
✅ 本篇实现了什么
本篇正式开始接入 Boss Blind 系统,并完成了第一版特殊规则架构的落地。
当前阶段主要完成了:
Boss Blind规则分类与边界划分Boss Blind配置结构设计initGame生命周期拆分Blind推进与ante配置管理SelectCardsResult与DealResult返回结构重构nextBlindConfig与nextAnteConfig的状态推进disableSuit与disableHandType两种结算型规则实现
本篇重点并不是一次性实现所有 Boss Blind,而是让特殊规则系统第一次真正进入当前后端架构。
且本阶段,不考虑过多重连等情况。
文章目录
- [一、Boss Blind 规则的分类与边界](#一、Boss Blind 规则的分类与边界)
-
- [1. 显示层规则](#1. 显示层规则)
- [2. 结算层规则](#2. 结算层规则)
- [3. 本篇最终规则实现选择](#3. 本篇最终规则实现选择)
- [二、Boss Blind 配置的结构设计](#二、Boss Blind 配置的结构设计)
-
- [1. 为什么不能直接把规则写死](#1. 为什么不能直接把规则写死)
- [2. 第一版的设计](#2. 第一版的设计)
- [3. type 能解决所有问题么?](#3. type 能解决所有问题么?)
- [4. 最终结构调整](#4. 最终结构调整)
- [三、为什么需要拆分 initGame 接口](#三、为什么需要拆分 initGame 接口)
-
- [1. startGame 开始承担越来越多的初始化逻辑](#1. startGame 开始承担越来越多的初始化逻辑)
- [2. ante 配置到底应该由谁维护?](#2. ante 配置到底应该由谁维护?)
- [3. initGame 最终负责什么?](#3. initGame 最终负责什么?)
- [4. initGame 代码实现](#4. initGame 代码实现)
-
- [4.1 initGame](#4.1 initGame)
- [4.2 createInitialGameState](#4.2 createInitialGameState)
- [4.3 getAnteConfig](#4.3 getAnteConfig)
- [四、SelectCardsResult 返回结构的重构](#四、SelectCardsResult 返回结构的重构)
-
- [1. 为什么要做 SelectCardsResult 的重构](#1. 为什么要做 SelectCardsResult 的重构)
- [2. 按职责拆分返回结构](#2. 按职责拆分返回结构)
- [3. 为什么仍然会保留可选字段](#3. 为什么仍然会保留可选字段)
- [4. DealResult 也同步调整](#4. DealResult 也同步调整)
- 五、特殊规则实现
-
- [1. 规则实现](#1. 规则实现)
-
- [1.1 applyDisableSuitEffect](#1.1 applyDisableSuitEffect)
- [1.2 isHandTypeDisabled](#1.2 isHandTypeDisabled)
- [1.3 特殊规则生效示例](#1.3 特殊规则生效示例)
- [2. 生命周期拆分后引发的状态问题](#2. 生命周期拆分后引发的状态问题)
- 六、总结
-
- [1. 当前阶段完成内容](#1. 当前阶段完成内容)
- [2. 本阶段最大的变化](#2. 本阶段最大的变化)
- [3. 当前 Boss Blind 系统状态](#3. 当前 Boss Blind 系统状态)
- [4. 当前阶段遗留问题](#4. 当前阶段遗留问题)
- [5. 下一步计划](#5. 下一步计划)
一、Boss Blind 规则的分类与边界
在写代码前,我先想的是 Boss Blind 的特殊规则有哪些?比如:
- 某个花色不让用户看到?
- 人头牌不让用户看到?
- 某个花色不计算分?
- 某个牌型不计算分?
但真正开始往下想后,我发现事情开始变复杂了。
因为这些规则里,有的是视觉层面的影响,有的则会真正影响游戏结算。
而且如果本篇一次性把所有 Boss Blind 都实现掉,代码量和逻辑边界都会瞬间扩大,感觉会直接炸掉。
后来我发现本篇其实不是实现所有的 Boss Blind,而是先建立 Boss Blind 规则系统的雏形。
所以这里,我先把 Boss Blind 的规则分成了两类。
#mermaid-svg-1U3MJPiDGrRl3LTG{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-1U3MJPiDGrRl3LTG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1U3MJPiDGrRl3LTG .error-icon{fill:#552222;}#mermaid-svg-1U3MJPiDGrRl3LTG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1U3MJPiDGrRl3LTG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG .marker.cross{stroke:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1U3MJPiDGrRl3LTG p{margin:0;}#mermaid-svg-1U3MJPiDGrRl3LTG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster-label text{fill:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster-label span{color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster-label span p{background-color:transparent;}#mermaid-svg-1U3MJPiDGrRl3LTG .label text,#mermaid-svg-1U3MJPiDGrRl3LTG span{fill:#333;color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .node rect,#mermaid-svg-1U3MJPiDGrRl3LTG .node circle,#mermaid-svg-1U3MJPiDGrRl3LTG .node ellipse,#mermaid-svg-1U3MJPiDGrRl3LTG .node polygon,#mermaid-svg-1U3MJPiDGrRl3LTG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .rough-node .label text,#mermaid-svg-1U3MJPiDGrRl3LTG .node .label text,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape .label,#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape .label{text-anchor:middle;}#mermaid-svg-1U3MJPiDGrRl3LTG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .rough-node .label,#mermaid-svg-1U3MJPiDGrRl3LTG .node .label,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape .label,#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape .label{text-align:center;}#mermaid-svg-1U3MJPiDGrRl3LTG .node.clickable{cursor:pointer;}#mermaid-svg-1U3MJPiDGrRl3LTG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG .arrowheadPath{fill:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1U3MJPiDGrRl3LTG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1U3MJPiDGrRl3LTG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1U3MJPiDGrRl3LTG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1U3MJPiDGrRl3LTG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1U3MJPiDGrRl3LTG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster text{fill:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster span{color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG 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-1U3MJPiDGrRl3LTG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG rect.text{fill:none;stroke-width:0;}#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape p,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape .label rect,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1U3MJPiDGrRl3LTG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1U3MJPiDGrRl3LTG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1U3MJPiDGrRl3LTG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Boss Blind
显示层规则
结算层规则
隐藏花色
隐藏人头牌
禁用花色
禁用牌型
前端负责处理
后端负责处理
1. 显示层规则
顾名思义,就是所有影响"视觉展示"的规则。比如:
- 某个花色不让用户看到
- 人头牌不让用户看到
这类的规则其根本:是后端仍然知道这张牌是什么,只是前端展示时不显示。例如:
ts
['AS', 'KH', 'QD']
后端仍然正常持有这些牌的数据。只是前端在渲染时,根据规则把部分牌显示成背面。
所以对于这类规则,后端其实不需要真正修改牌本身,而是只需要提供规则配置。例如:
ts
hiddenSuit: 'S'
真正去处理这个规则的是前端,而后端需要做的,是提供当前 Boss Blind 的规则信息。
2. 结算层规则
另一类,则是会真正影响游戏结算的规则。
比如:
- 某个花色不计算分
- 某个牌型不计算分
这类必须是由后端处理,因为它影响实际结算的结果。而目前牌型的分数计算,牌型的判断,也都是由后端处理。
例如当前 Boss Blind 的规则配置是:
ts
disabledSuit: 'H'
那么玩家出了:
ts
['AH', 'KH', 'QH', 'JH', '10H']
虽然看起来是同花顺,但如果红桃不计分,那这些牌在得分计算时就要被过滤或失效。
这类规则,本质上属于:会干预游戏结算结果的规则。所以必须由后端处理。
3. 本篇最终规则实现选择
如果本篇一次性到位,同时去做:
- 盖住花色
- 盖住人头牌
- 某花色不计分
- 某牌型不计分
- 首次出牌必须包含某花色
- 手牌减少
- 出牌次数减少
- 特定条件限制
对我来说周期太长,开发边界也会瞬间扩大,感觉会炸。
所以本篇最终决定:先只实现部分"结算层规则"。先做一个最小闭环:
Boss Blind 配置
↓
当前 ante 读取 Boss 规则
↓
selectCards 结算时应用规则
↓
测试验证规则生效
👉 也就是说:这一篇真正的重点,并不是一次性实现多少 Boss Blind。而是:让规则系统第一次真正进入后端架构。
但真正让我意识到 Boss Blind 复杂的,并不是规则本身,而是我发现:从这一阶段开始,游戏已经不再只是"算分",而是开始出现了"修改规则"。
而修改规则,往往比新增功能更容易破坏原有结构。因为新增功能通常只是扩展能力,而规则系统会直接影响已有流程。
二、Boss Blind 配置的结构设计
1. 为什么不能直接把规则写死
在真正开始设计 Boss Blind 配置的时候,我一开始想的是:既然当前已经确定,本篇只做结算层规则,那是不是直接写对应逻辑就好了?
比如:
- 某个花色不计分
- 某个牌型不计分
但真正开始往下写后,我发现问题没有想象的那么简单。
因为现在虽然只实现结算层规则 ,但未来 Boss Blind 一定不只是后端自己用。
例如之前提到的一些显示层的规则:
- 某个花色不让用户看到
- 人头牌不让用户看到
虽然当前不会真正去实现,但如果现在直接把配置结构写死,未来前端接入的时候,整体结构可能又要重新推翻。
所以这里其实有一个很重要的问题:
Boss Blind的配置,不仅后端要能读懂,未来前端也要能读懂。
也就是说:这个配置最终应该是一个规则描述 ,而不是单纯的一对 if-else。
2. 第一版的设计
最开始的时候,我的想法其实很简单。直接使用Object key-value 的形式:
key对应antevalue对应当前Boss Blind的配置
例如:
ts
export const BOSS_BLIND_CONFIG = {
1: {
name: "The Club",
effect: {
type: "disableSuit",
suit: "C",
},
},
2: {
name: "The Diamond",
effect: {
type: "disableSuit",
suit: "D",
},
},
...
}
这样写的最大好处就是简单。当前 ante 对应哪个 Boss Blind, 直接读取即可。
但真正写完后,我发现这里其实有不少的问题。
首先是 name, 如果未来是通过:name === 'The Club' 的方式去判断规则,那么问题会很多。
因为:
- 大小写问题
- 空格问题
- 拼写问题
- 前后端字符串是否统一的问题
这些问题都可能会出现。而且 name 本身更适合作为展示字段,而不是决策字段。
如果后面真正进入规则逻辑阶段,还继续依赖字符串判断,会越来越乱。
3. type 能解决所有问题么?
然后我又开始想:既然 name 不适合作为规则表示,那是不是直接通过 type 去区分就好了?
比如 type: "disableSuit" 代表的是:某个花色不计分。
但继续往后想,我又发现: type 其实只能描述规则类型。例如 disableSuit 代表的是:禁用某个花色。
但未来如果还存在:
- 禁用某个牌型
- 首次出牌必须包含某个花色
- 隐藏某个花色
- 隐藏人头牌
等等更多规则时。
真正的问题其实就变成了:当前到底是哪一个 Boss Blind?
也就是说:
Boss Blind本身需要有自己的唯一标识Boss Blind的规则效果则应该独立描述
4. 最终结构调整
所以最后,我决定把 Boss Blind 的身份、Boss Blind 的规则效果拆成两个部分。
#mermaid-svg-EBhFNMsbLIWTNvMc{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-EBhFNMsbLIWTNvMc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EBhFNMsbLIWTNvMc .error-icon{fill:#552222;}#mermaid-svg-EBhFNMsbLIWTNvMc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EBhFNMsbLIWTNvMc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc .marker.cross{stroke:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EBhFNMsbLIWTNvMc p{margin:0;}#mermaid-svg-EBhFNMsbLIWTNvMc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster-label text{fill:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster-label span{color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster-label span p{background-color:transparent;}#mermaid-svg-EBhFNMsbLIWTNvMc .label text,#mermaid-svg-EBhFNMsbLIWTNvMc span{fill:#333;color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .node rect,#mermaid-svg-EBhFNMsbLIWTNvMc .node circle,#mermaid-svg-EBhFNMsbLIWTNvMc .node ellipse,#mermaid-svg-EBhFNMsbLIWTNvMc .node polygon,#mermaid-svg-EBhFNMsbLIWTNvMc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .rough-node .label text,#mermaid-svg-EBhFNMsbLIWTNvMc .node .label text,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape .label,#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape .label{text-anchor:middle;}#mermaid-svg-EBhFNMsbLIWTNvMc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .rough-node .label,#mermaid-svg-EBhFNMsbLIWTNvMc .node .label,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape .label,#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape .label{text-align:center;}#mermaid-svg-EBhFNMsbLIWTNvMc .node.clickable{cursor:pointer;}#mermaid-svg-EBhFNMsbLIWTNvMc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc .arrowheadPath{fill:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EBhFNMsbLIWTNvMc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EBhFNMsbLIWTNvMc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EBhFNMsbLIWTNvMc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EBhFNMsbLIWTNvMc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EBhFNMsbLIWTNvMc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster text{fill:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster span{color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc 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-EBhFNMsbLIWTNvMc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc rect.text{fill:none;stroke-width:0;}#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape p,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape .label rect,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EBhFNMsbLIWTNvMc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EBhFNMsbLIWTNvMc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EBhFNMsbLIWTNvMc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Boss Blind 配置
Boss 身份信息
Boss 规则效果
code: 101
name: The Club
effect.type: disableSuit
effect.suit: C
用于唯一标识 Boss
用于前端展示
决定规则类型
决定规则作用目标
首先新增 Code
ts
export const BOSS_BLIND_CODE = {
THE_CLUB: 101,
THE_DIAMOND: 102,
THE_HEART: 103,
THE_SPADE: 104,
THE_STRAIGHT_FLUSH: 105,
THE_FOUR_OF_A_KIND: 106,
THE_FULL_HOUSE: 107,
THE_FLUSH: 108,
THE_STRAIGHT: 109,
THE_THREE_OF_A_KIND: 110,
THE_TWO_PAIR: 111,
THE_ONE_PAIR: 112,
THE_HIGH_CARD: 113
};
然后再通过 code 去对应真正的配置:
ts
export const BOSS_BLIND_CONFIG = {
[BOSS_BLIND_CODE.THE_CLUB]: {
code: BOSS_BLIND_CODE.THE_CLUB,
name: "The Club",
effect: { type: "disableSuit", suit: "C" },
},
[BOSS_BLIND_CODE.THE_DIAMOND]: {
code: BOSS_BLIND_CODE.THE_DIAMOND,
name: "The Diamond",
effect: { type: "disableSuit", suit: "D" },
},
...
}
这样拆分后,整体职责会清晰很多:
code: 标识当前是哪一个Boss Blind.name: 用于展示effect.type: 标识当前规则属于哪种类型effect里的参数:描述具体规则作用到哪里
这样未来不管是
- 前端展示层规则
- 后端结算层规则
- 新增更多的
Boss Blind
整体结构都会更容易扩展,至少下一次新增 Boss Blind 时,我需要修改的可能只是配置,而不一定是规则代码本身,这样也避免了代码最终变成一堆无限嵌套的 if-else.
三、为什么需要拆分 initGame 接口
1. startGame 开始承担越来越多的初始化逻辑
最开始的时候,我其实没打算再本篇新增接口。
因为我感觉,当前已有的:startGame、selectCards 已经足够承担这次 Boss Blind 的特殊规则相关的逻辑了。
但随着 Boss Blind 的配置和逻辑逐渐开始落地后,我发现: startGame 开始承担越来越多的初始化流程了。
之前项目还比较简单的时候,我一直觉得:初始化放在 startGame 里并没有什么问题。
因为那时候:
- 游戏状态简单
Blind逻辑还没接入ante也并不存在Boss Blind更不存在
所以:
startGame = 初始化 + 发牌
#mermaid-svg-cJOxyqBDoRzTcODP{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-cJOxyqBDoRzTcODP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cJOxyqBDoRzTcODP .error-icon{fill:#552222;}#mermaid-svg-cJOxyqBDoRzTcODP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cJOxyqBDoRzTcODP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP .marker.cross{stroke:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cJOxyqBDoRzTcODP p{margin:0;}#mermaid-svg-cJOxyqBDoRzTcODP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster-label text{fill:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster-label span{color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster-label span p{background-color:transparent;}#mermaid-svg-cJOxyqBDoRzTcODP .label text,#mermaid-svg-cJOxyqBDoRzTcODP span{fill:#333;color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .node rect,#mermaid-svg-cJOxyqBDoRzTcODP .node circle,#mermaid-svg-cJOxyqBDoRzTcODP .node ellipse,#mermaid-svg-cJOxyqBDoRzTcODP .node polygon,#mermaid-svg-cJOxyqBDoRzTcODP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .rough-node .label text,#mermaid-svg-cJOxyqBDoRzTcODP .node .label text,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape .label,#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape .label{text-anchor:middle;}#mermaid-svg-cJOxyqBDoRzTcODP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .rough-node .label,#mermaid-svg-cJOxyqBDoRzTcODP .node .label,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape .label,#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape .label{text-align:center;}#mermaid-svg-cJOxyqBDoRzTcODP .node.clickable{cursor:pointer;}#mermaid-svg-cJOxyqBDoRzTcODP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP .arrowheadPath{fill:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cJOxyqBDoRzTcODP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cJOxyqBDoRzTcODP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cJOxyqBDoRzTcODP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cJOxyqBDoRzTcODP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cJOxyqBDoRzTcODP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cJOxyqBDoRzTcODP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster text{fill:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster span{color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP 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-cJOxyqBDoRzTcODP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cJOxyqBDoRzTcODP rect.text{fill:none;stroke-width:0;}#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape p,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape .label rect,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cJOxyqBDoRzTcODP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cJOxyqBDoRzTcODP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cJOxyqBDoRzTcODP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 拆分前:startGame
初始化 GameState
初始化 playerState
初始化 blindState
生成 ante 配置
发牌并进入游戏
是完全够用的,但现在已经开始不一样了。
2. ante 配置到底应该由谁维护?
随着 ante、Blind、Boss Blind 的加入,一个新的问题开始出现了。
前端需要在游戏开始前,就拿到当前 ante 对应的配置:
- 大盲
- 小盲
- Boss Blind
我最开始落地的第一版代码里,是直接把这些配置放进 DealResult。
也就是说:每次 startGame 的时候,前端都能拿到当前 ante 的全部配置。
但继续往后写的时候,又有了新的问题:
当前
ante可以放在startGame,那下一个ante的配置怎么办?
再往 selectCardsResult 塞一个字段?或者直接在 startGame 的时候,把所有 ante 的配置一次性全部返回给前端?
但这个方案我很快就放弃了。因为这样会让前端开始承担游戏进度推导:
text
Boss 结束
↓
前端自己 ante + 1
↓
读取下一关配置
当前看起来可能没什么问题。但未来一旦出现:
- 跳过
Blind Boss特殊效果- 游戏失败重开
- 中途恢复
- 难度调整
- 临时事件
前端自己推导状态,就非常容易和后端产生不一致。
所以最后,我还是遵循了一个最基础的原则:
前端负责展示状态,后端负责推进状态。
也就是说,ante 和 Blind 的推进应该由后端统一维护,前端只消费后端返回的当前状态和下一步展示配置。
3. initGame 最终负责什么?
但新的问题又出现了...
如果 selectCards 负责返回推进后的配置,那么:游戏第一次初始化时的 ante 配置,又该由谁返回?
答案是:
❌没有。
因为现在已有的接口里:
startGame:更偏向 -> 正式开始当前BlindselectCards:更偏向 -> 结算与推进
它们都已经不太适合继续承担:"整局游戏初始化"这件事了
所以最终,我决定新增:initGame 接口。
它主要负责:
- 初始化整局游戏状态
- 初始化
blindState - 初始化
ante配置 - 初始化未来可能新增的状态数据
而 startGame 则只负责:开始当前 Blind,并完成发牌。
这样拆分后,每一个接口的职责也会开始变得更加清晰。
#mermaid-svg-dHrv1u4NQlQQzT1f{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-dHrv1u4NQlQQzT1f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dHrv1u4NQlQQzT1f .error-icon{fill:#552222;}#mermaid-svg-dHrv1u4NQlQQzT1f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dHrv1u4NQlQQzT1f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f .marker.cross{stroke:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dHrv1u4NQlQQzT1f p{margin:0;}#mermaid-svg-dHrv1u4NQlQQzT1f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster-label text{fill:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster-label span{color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster-label span p{background-color:transparent;}#mermaid-svg-dHrv1u4NQlQQzT1f .label text,#mermaid-svg-dHrv1u4NQlQQzT1f span{fill:#333;color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .node rect,#mermaid-svg-dHrv1u4NQlQQzT1f .node circle,#mermaid-svg-dHrv1u4NQlQQzT1f .node ellipse,#mermaid-svg-dHrv1u4NQlQQzT1f .node polygon,#mermaid-svg-dHrv1u4NQlQQzT1f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .rough-node .label text,#mermaid-svg-dHrv1u4NQlQQzT1f .node .label text,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape .label,#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape .label{text-anchor:middle;}#mermaid-svg-dHrv1u4NQlQQzT1f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .rough-node .label,#mermaid-svg-dHrv1u4NQlQQzT1f .node .label,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape .label,#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape .label{text-align:center;}#mermaid-svg-dHrv1u4NQlQQzT1f .node.clickable{cursor:pointer;}#mermaid-svg-dHrv1u4NQlQQzT1f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f .arrowheadPath{fill:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dHrv1u4NQlQQzT1f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dHrv1u4NQlQQzT1f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dHrv1u4NQlQQzT1f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dHrv1u4NQlQQzT1f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dHrv1u4NQlQQzT1f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster text{fill:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster span{color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f 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-dHrv1u4NQlQQzT1f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f rect.text{fill:none;stroke-width:0;}#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape p,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape .label rect,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dHrv1u4NQlQQzT1f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dHrv1u4NQlQQzT1f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dHrv1u4NQlQQzT1f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} initGame
初始化 GameState
初始化 playerState
初始化 blindState
生成 currentAnteConfig
startGame
基于当前 Blind 发牌
重置 plays/discards
进入 playing 状态
这样一来:
initGame:负责整局游戏初始化startGame:负责当前Blind开始selectCards:负责出牌结算与Blind推进
三个接口的职责会更清楚。
4. initGame 代码实现
gateway文件中的代码不再展示,主要以game.service.ts为主
4.1 initGame
ts
/**
* Initializes the full game lifecycle.
*
* This method is responsible for:
* - creating initial game state
* - initializing blind progression
* - generating ante configuration
*
* It does not deal cards.
* Actual gameplay starts in startGame().
*/
initGame(playerId: string): GameState {
if (this.gameStates[playerId]) delete this.gameStates[playerId];
if (this.bossBlindAssignments[playerId]) delete this.bossBlindAssignments[playerId];
const gameState: GameState = this.createInitialGameState(playerId);
this.gameStates[playerId] = gameState;
return gameState;
}
4.2 createInitialGameState
这里最重要的是:
gameStatus初始化为initializedcurrentAnteConfig会在游戏真正开始前就生成blindState正式开始独立承担Blind生命周期
ts
private createInitialGameState(playerId: string): GameState {
const currentAnteConfig = this.getAnteConfig(playerId, 1);
this.gameStates[playerId] = {
playerId: playerId,
playerState: {...},
blindState: {
round: 1,
ante: 1,
blindType: "small",
targetScore: currentAnteConfig.small.score,
currentAnteConfig: currentAnteConfig,
currentBlindScore: 0,
},
gameStatus: "initialized",
};
return this.gameStates[playerId];
}
4.3 getAnteConfig
这里 Boss Blind 会在 ante 初始化时就确定,并且由后端统一维护 Blind 配置。
ts
private getAnteConfig(playerId: string, ante: number): AnteConfig {
let bossBlindAssignmentsByPlayer = this.bossBlindAssignments[playerId];
...
const code = bossBlindAssignmentsByPlayer.pop();
const currentAnteConfig = {
ante,
small: {...},
big: {...},
boss: {
score: ...,
code,
name: ...
}
};
...
return currentAnteConfig;
}
四、SelectCardsResult 返回结构的重构
1. 为什么要做 SelectCardsResult 的重构
在本篇准备接入 Boss Blind 相关逻辑后,我发现项目里很多原本还能用的结构,都开始有点撑不住了。
比如前面拆了 initGame 接口,现在又轮到了 SelectCardsResult。都是一开始没有想过会动的。因为 SelectCardsResult 本来只是用来返回一次出牌后的结果。
但随着 Blind、ante、Boss Blind、结算状态这些内容逐渐加入后,它返回的信息越来越多,结构也开始变得很散。
原来的结构大概是这样的:
ts
export type SelectCardsResult = {
code: number;
selectedCards?: string[];
gameOver?: boolean;
blindOver?: boolean;
remainingDeckCount?: number;
playerState?: GameStateResponse;
cardType?: number;
validCards?: string[];
baseScore?: number;
multiplier?: number;
round?: number;
ante?: number;
blindType?: BlindType;
settlement?: {
finalScore: number;
targetScore: number;
result: "WIN" | "LOSE";
};
currentAnteConfig?: AnteConfig;
nextAnteConfig?: AnteConfig;
};
就目前而言,它已经有点像大杂烩了。
有的是本次出牌的结果,比如⬇️:
selectedCardscardTypevalidCardsbaseScoremultiplier
有的是玩家当前状态,比如⬇️:
remainingDeckCountplayerState
有的是 Blind 相关的状态,比如⬇️:
roundanteblindType
有的是关卡推进和结算结果,比如⬇️:
blindOvergameOversettlementcurrentAnteConfignextAnteConfig
也就是说,SelectCardsResult 已经不只是出牌结果了。它现在同时包含了:
- 本次动作结果
- 玩家状态
Blind状态- 关卡推进信息
如果继续把所有字段都平铺在最外层,后面再继续加 Boss Blind 的规则效果,只会越来越乱。所以这里我决定做一次结构重构。
2. 按职责拆分返回结构
这次重构的目标,不是为了让代码看起来更高级。而是为了让返回值本身更清楚。
所以我把 SelectCardsResult 按职责拆分了几部分:
ts
export type SelectCardsResult = {
code: number;
message: string;
action?: "play" | "discard";
scoreDetail?: {
selectedCards: string[];
cardType: number;
validCards: string[];
baseScore: number;
multiplier: number;
};
blindState?: {
round: number;
ante: number;
blindType: BlindType;
targetScore: number;
currentBlindScore: number;
};
playerState?: GameStateResponse;
progress?: Progress;
};
/**
* Represents progression-related game information.
*
* Includes:
* - blind completion
* - settlement result
* - next blind preview
* - next ante preview
*/
export type Progress = {
gameOver: boolean;
blindOver: boolean;
settlement?: {
finalScore: number;
targetScore: number;
result: "WIN" | "LOSE";
};
currentAnteConfig: AnteConfig;
nextAnteConfig?: AnteConfig;
nextBlindConfig?: NextBlindConfig;
};
拆完后,结构会清楚很多。
scoreDetail: 表示本次出牌动作的结果playerState: 表示玩家当前状态blindState: 表示当前Blind状态progress: 表示关卡是否结束、是否通关、下一关配置等推进信息
✅这样一来,返回值就不再是一堆字段堆在一起了。而是能看出来每一部分分别在表达什么。
3. 为什么仍然会保留可选字段
这里还有一个问题。因为有些请求可能在最前面就被拦截了。
比如:
- 参数错误
- 游戏不存在
- 游戏已经结束
- 选择的牌不合法
像这些情况下,其实没有必要返回完整的 scoreDetail、playerState、blindState 和 progress。
只需要返回:
ts
{ code, message}
就已经足够了。
所以重构后,并不是完全不需要 ?。
而是把原来散落在最外层的一堆可选字段,收敛成几个明确的模块:
ts
scoreDetail?
playerState?
blindState?
progress?
失败时可以只返回错误信息。成功时再返回完整的结构。
这样既保留了接口的灵活性,也不会让整个返回类型变得特别散。
4. DealResult 也同步调整
在调整 SelectCardsResult 的时候,我顺手也把 DealResult 做了同样的整理。
因为 DealResult 其实也有类似的问题。
它既包含玩家手牌信息,也包含当前 Blind 信息。所以最后也拆成了:
ts
export type DealResult = {
code: number;
playerState?: {
hand: string[];
remainingDeckCount: number;
playsLeft: number;
discardsLeft: number;
};
blindState?: {
round: number;
ante: number;
blindType: BlindType;
targetScore: number;
currentAnteConfig: AnteConfig;
};
};
这样 startGame 的返回值也会更清楚:
playerState: 当前发牌后的玩家状态blindState: 当前Blind相关状态
这次重构虽然改动不算小,但它不是为了'好看'而改。而是因为随着 Boss Blind 和关卡推进逻辑的加入,返回结构已经开始承载更多的职责。
如果不提前整理,后面真正接入特殊规则的时候,只会越来越难维护。
📌 至于为什么没有在最开始设计参数结构的时候,就提前完成职责划分。
原因很简单:当时并没有想到后面会接入这么多状态和返回数据。
所以当前能做的,也只能是在每一次重构后,让结构比之前更清晰一些,并尽量为未来的扩展提前留出空间。
五、特殊规则实现
碎碎念: 终于可以开始写特殊规则实现了。。。原本本篇只需要写特殊规则实现的,真的是不动不知道,一动吓一跳😂
1. 规则实现
本次只针对两种结算型的特殊规则做处理:
- 某种花色不计分
- 某种牌型不计分
前面花了很多篇幅调整配置、接口和返回结构,但真正接入 disableSuit 和 disableHandType 时,代码反而没有想象中复杂。
因为规则已经被配置描述出来了 ,后端只需要在计分入口根据 effect.type 做不同处理。
这次提前考虑到了后续可能还有的特殊规则,所以并没有把特殊规则的逻辑全部都放到 calculateHandScore。
而是进行拆分出了两个小接口,只在 calculateHandScore 做了判断。
这里其实还有一个设计选择。最开始的时候,我也想过直接在 calculateHandScore 里面写:
ts
if (...)
if (...)
if (...)
这样其实也能跑。但很快我发现一个问题:当前只有 disableSuit 和 disableHandType 两个规则。
未来一定还会有:
- 隐藏花色
- 隐藏人头牌
- 出牌限制
- 手牌限制
- 特殊条件触发
如果所有规则都不断堆积在 calculateHandScore 中,那么这个接口最终只会越来越难维护。
所以虽然当前只实现了两个特殊规则,但我还是提前把规则逻辑拆成独立方法。这样未来新增规则时,更多是在扩展,而不是不断修改已有逻辑。
ts
public calculateHandScore(cards: string[], bossCode: number): {...} {
const effect: BossEffect | null = BOSS_BLIND_CONFIG[bossCode]?.effect as BossEffect | null;
let scoringCards: string[] = cards;
scoringCards = this.applyDisableSuitEffect(scoringCards, effect);
if (scoringCards.length == 0) {
return {
baseScore: 0,
multiplier: 0,
handType: CARD_TYPE.highCard,
validCards: [],
};
}
...
if (this.isHandTypeDisabled(cardType, effect)) {
baseScore = 0;
multiplier = 0;
validCards = [];
}
return { ... };
}
这样一来,后续再增加其他的特殊规则判断,也只需要根据情况再扩展新的接口就够了。
并且在未来也可以灵活的迁移到其他的文件中。但目前因只实现了两个结算型规则,而当前 effect 数量还比较少,所以暂时没有单独拆 effect 系统。。
1.1 applyDisableSuitEffect
ts
private applyDisableSuitEffect(cards: string[], effect: BossEffect | null): string[] {
if (effect?.type !== "disableSuit") return cards;
return cards.filter((card) => !card.endsWith(effect.suit));
}
1.2 isHandTypeDisabled
ts
private isHandTypeDisabled(cardType: number, effect: BossEffect | null): boolean {
return effect?.type === "disableHandType" && cardType === CARD_TYPE[effect.handType];
}
1.3 特殊规则生效示例
- 例如当前 Boss Blind 为:
ts
{
type: "disableSuit",
suit: "H"
}
- 玩家出牌:
ts
['AH', 'KH', 'QH', 'JH', '10H']
- 过滤后:
ts
[]
- 最终结果:
ts
baseScore = 0
multiplier = 0
validCards = []
也就是说:虽然玩家看起来出了同花顺。但因为红桃被禁用,最终这一手不会获得任何分数。
2. 生命周期拆分后引发的状态问题
在实现后的测试过程中,还发现了一个问题。就是用户出牌的次数返回给前端的结果中,是最新的,但实际服务器的缓存中,随着每个 round 每个 blind 的使用,并没有实际的更新。
在查明问题的时候,发现本次拆分了原本的 startGame 衍生了 initGame。
在 initGame 的时候虽然进行了用户 playsLeft、discardsLeft、currentActionScore、currentBlindScore 的初始化。
但是原有在 startGame 去重置出牌、弃牌次数的相关逻辑,也被删除掉了,从而发现了当前的问题。
最终在 startGame 的时候添加了用户重置数据的逻辑
ts
playerState.playsLeft = GAME_RULE.INITIAL_PLAYS_LEFT;
playerState.discardsLeft = GAME_RULE.INITIAL_DISCARDS_LEFT;
playerState.currentActionScore = 0;
blindState.currentBlindScore = 0;
这次问题也让我意识到:接口职责拆分以后,除了要迁移业务逻辑,还要重新检查生命周期。
因为很多初始化代码原本隐藏在旧接口内部,当接口被拆开时,这些逻辑未必会一起被迁移。代码结构虽然完成了解耦,但状态生命周期却可能被意外拆断。
而很多 Bug 并不是新功能本身导致的,反而是在重构过程中,原本隐藏在旧流程里的生命周期逻辑被遗漏了。
相比于功能开发,这类问题往往更难发现...
六、总结
1. 当前阶段完成内容
本篇正式完成了 Boss Blind 系统的第一版接入,并完成了部分特殊规则的落地。
当前阶段主要实现了:
✔ Boss Blind 规则分类与边界划分
✔ Boss Blind 配置结构设计
✔ initGame 生命周期拆分
✔ Blind 与 ante 状态推进
✔ nextBlindConfig/nextAnteConfig 返回
✔ SelectCardsResult 返回结构重构
✔ DealResult 返回结构重构
✔ disableSuit 特殊规则实现
✔ disableHandType 特殊规则实现
2. 本阶段最大的变化
本篇最大的变化,其实并不是 Boss Blind 规则本身。
而是:随着 Boss Blind、Blind 推进、ante 等系统逐渐加入后,原本很多还能继续使用的结构,已经开始慢慢撑不住了。
所以这一篇里,出现了大量原本没有计划的重构:
initGame接口拆分- 生命周期重新划分
- 返回结构职责拆分
Blind推进状态管理- 前后端职责重新划分
也是在这个过程中,我越来越明显地感觉到:
真正复杂的,往往不是功能本身,而是旧结构还能不能继续承载新功能。
3. 当前 Boss Blind 系统状态
目前这一版 Boss Blind 还只是一个开始。
当前只实现了:
- 某个花色不计分
- 某个牌型不计分
也就是:只完成了部分"结算层规则"。
而像:
- 隐藏花色
- 隐藏人头牌
- 行为限制
- 状态限制
- 更复杂的规则组合
这些都还没有真正接入。
但至少现在,整个 Boss Blind 的规则系统已经第一次真正进入了当前后端架构。
4. 当前阶段遗留问题
虽然当前结构已经比之前清晰很多,但还有一些问题暂时没有继续处理。
例如:
Boss Blindeffect 还没有真正独立成effect system- 特殊规则仍然集中在
poker.service.ts - 规则之间还不存在组合机制
- 当前仍然缺少真正完整的游戏流程联调
不过这些问题,我暂时没有继续提前拆。
因为当前 effect 数量还不算多,过早拆分反而可能会让结构变得过度复杂。
5. 下一步计划
下一阶段,会开始继续完善 Boss Blind 与游戏流程本身。
包括:
- 更多特殊规则扩展
Blind生命周期继续完善Boss Blind与回合流程联动- 更完整的游戏状态推进
- 可能开始接入跳过
Blind的逻辑
不过真正写完这一篇之后,我发现 Boss Blind 最麻烦的地方,可能还不是这些特殊规则本身。
因为 disableSuit、disableHandType 目前都还能通过几个独立函数解决。
但如果未来同时出现:
- 禁用花色
- 禁用牌型
- 手牌减少
- 出牌次数减少
- 特殊条件限制
这些规则开始叠加的时候,当前结构还能不能继续承载?
这可能才是下一阶段真正需要面对的问题。而这,也是后面 Modifier、Joker,以及更复杂规则系统最终会带来的挑战。