本系列记录我从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。
- 该项目的代码已开源,可在 GitHub 与 GitCode 上获取。
- 📌 本文对应代码分支:
origin/feature/tag-system - ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。
- 📌 本文对应代码分支:
✅ 本篇实现了什么
本篇主要围绕 skip blind 与 Tag reward 机制,完成了从机制分析到基础代码落地的第一版实现。
当前阶段主要完成了:
- 梳理
skip blind的机制意义与玩家选择动机 - 明确
skip与tag的关系,区分奖励预览与玩家真实获得状态 - 新增
tag.config.ts,建立当前阶段的基础Tag配置结构 - 在
initGame阶段为small blind/big blind提前分配tag - 新增
skipBlind独立接口,打通跳过blind的基础流程 - 实现两个代表性
TagBoss Tag:即时改写当前ante的Boss BlindJuggle Tag:延迟到下一局startGame生效
- 增加
activeTags、cleanupExpiredTags、clearPlayerRuntimeState等运行时状态处理逻辑
也就是说,这一篇真正完成的,并不是一次性把全部 Tag 都实现完,而是先把 Skip Blind + Tag Reward 的第一条可运行链路 跑通。
文章目录
- [一、为什么需要跳过 Blind 机制](#一、为什么需要跳过 Blind 机制)
- [二、玩家为什么会选择 skip blind](#二、玩家为什么会选择 skip blind)
-
- [2.1 前期关卡太简单,想快速跳关](#2.1 前期关卡太简单,想快速跳关)
- [2.2 当前展示出来的 Tag 很有吸引力](#2.2 当前展示出来的 Tag 很有吸引力)
- [2.3 当前构筑偏弱,想赌一个变量](#2.3 当前构筑偏弱,想赌一个变量)
- [2.4 当前构筑与 skip 有联动](#2.4 当前构筑与 skip 有联动)
- [三、Skip 与 Tag 在机制上的关系](#三、Skip 与 Tag 在机制上的关系)
-
- [3.1 skip 不是一个普通按钮](#3.1 skip 不是一个普通按钮)
- [3.2 skip 真正替换掉的是什么](#3.2 skip 真正替换掉的是什么)
- [3.3 Tag 不是 Blind 自己的属性](#3.3 Tag 不是 Blind 自己的属性)
- [3.4 skip 不是永久改线,而是局部分叉](#3.4 skip 不是永久改线,而是局部分叉)
- [3.5 为什么 skip + tag 会让整局变得更有意思](#3.5 为什么 skip + tag 会让整局变得更有意思)
- [四、状态设计:Tag 如何进入当前游戏状态](#四、状态设计:Tag 如何进入当前游戏状态)
-
- [4.1 初始化阶段,先生成当前 blind 对应的 skip reward](#4.1 初始化阶段,先生成当前 blind 对应的 skip reward)
- [4.2 挂载到 blindState 的,是奖励预览,不是玩家状态](#4.2 挂载到 blindState 的,是奖励预览,不是玩家状态)
- [4.3 玩家真正获得 Tag 的时机,是执行 skip 之后](#4.3 玩家真正获得 Tag 的时机,是执行 skip 之后)
- [4.4 为什么不想把 reward 直接写死在 Blind 配置里](#4.4 为什么不想把 reward 直接写死在 Blind 配置里)
- [五、🎯 奖励触发与结算流程实现](#五、🎯 奖励触发与结算流程实现)
-
- [5.1 Tag 配置放到哪里?](#5.1 Tag 配置放到哪里?)
-
- [5.1.1 当前配置相关的目录结构](#5.1.1 当前配置相关的目录结构)
- [5.1.2 tag.config 代码示例](#5.1.2 tag.config 代码示例)
- [5.1.3 tag.config 的使用](#5.1.3 tag.config 的使用)
- [5.2 skipBlind](#5.2 skipBlind)
-
- [5.2.1 为什么单独做 skipBlind 接口](#5.2.1 为什么单独做 skipBlind 接口)
- [5.2.2 skipBlind 代码实现](#5.2.2 skipBlind 代码实现)
- [5.2.3 为什么需要 activeTags](#5.2.3 为什么需要 activeTags)
- [5.2.4 Juggle Tag 如何在下一局生效](#5.2.4 Juggle Tag 如何在下一局生效)
- [5.2.5 为什么还需要 cleanupExpiredTags](#5.2.5 为什么还需要 cleanupExpiredTags)
- [5.2.6 blind 结束后的推进逻辑为什么要拆出去](#5.2.6 blind 结束后的推进逻辑为什么要拆出去)
- [5.2.7 当前链路验证结果](#5.2.7 当前链路验证结果)
- [六、当前实现边界:先支持影响 Blind 的 Tag](#六、当前实现边界:先支持影响 Blind 的 Tag)
-
- [6.1 为什么现在不适合把 tag 做得太大](#6.1 为什么现在不适合把 tag 做得太大)
- [6.2 为什么先按"影响哪个系统"来分类](#6.2 为什么先按“影响哪个系统”来分类)
- [6.3 为什么不是一次性支持所有的 tag](#6.3 为什么不是一次性支持所有的 tag)
- [6.4 在 blind 内,为什么我最先想到的是 target / rule](#6.4 在 blind 内,为什么我最先想到的是 target / rule)
- [6.5 当前阶段的实现策略:先跑通最小链路](#6.5 当前阶段的实现策略:先跑通最小链路)
- 七、总结
-
- [7.1 当前阶段完成内容](#7.1 当前阶段完成内容)
- [7.2 这一阶段真正难的地方](#7.2 这一阶段真正难的地方)
- [7.3 当前并不是完整 Tag 系统](#7.3 当前并不是完整 Tag 系统)
- [7.4 下一步会继续补什么](#7.4 下一步会继续补什么)
一、为什么需要跳过 Blind 机制
一开始我去看 Balatro 这个游戏的时候,发现一个很有意思的点:并不是所有的 Blind 都能跳过,而是只有 small blind 和 big blind 可以,boss blind 是不行的。
因为玩家真正放弃的,不只是当前这一关的对局本身,而是这次正常推进后能拿到的一整套稳定收益。比如正常打完一个 blind,后面还能进 shop,还能继续买卖特殊牌从而调整当前的特殊牌构筑,看看有没有更适合的小丑牌或者别的资源。
所以 skip blind 的本质,并不是"白拿奖励",而是:
放弃当前一次稳定的成长机会,去换一个不同方向的收益。
也正是因为这样, skip 才不是一个可有可无的小按钮。如果没有这个机制,整个流程其实会很稳定,从而变成流水线:small blind -> big blind -> boss blind -> shop -> next ante。这一套组合拳下来,玩家能做的就是一路按照既定节奏往下打。
但 skip 的出现,相当于在阶段流程里插了一个分叉口。你可以正常走,也可以放弃这一次的正常路径,去换取一个当前看起来更值,或者更适合当前局面的奖励。
所以我现在会觉得,skip blind 机制存在的意义,不只是"让玩家少打一关",而是为了让每个阶段都多一个选择,让游戏变得更有意思。
二、玩家为什么会选择 skip blind
如果只是从表面看,玩家选择 skip blind,好像只是因为"奖励不错"。但实际上,我觉得玩家会跳过 blind ,动机并不止是一个。
我目前能想到的大概有下面几类(分析为个人的思路理解,仅供参考):
【如果你有别的想法,或者觉得还有我没想到的情况,欢迎帮我一起拓展这块的思路。】
2.1 前期关卡太简单,想快速跳关
有些玩家已经循环玩过很多次了,对前期的节奏比较熟悉。这时候某些前期 blind 对她来说,可能已经不算挑战,更多只是重复劳动。
那这种情况下,玩家可能就会想:
这关我不是打不过,而是懒得再打一遍。
这种动机更像是一种 efficiency choice,不是因为缺资源,而是因为当前关卡的时间成本已经高于它本身的挑战价值了。
2.2 当前展示出来的 Tag 很有吸引力
这个就更直接了。
如果当前 small blind 或 big blind 展示出来的 tag 很香,而且我现在这套牌组也还比较满意,没有特别急着进 shop 去修改,那我就会很自然地开始做权衡:
- 我是正常打一关,然后进
shop - 还是现在直接
skip,把这个tag拿走
这时候玩家比较的,其实不是"跳关 vs 不跳关",而是:
- 当前这一次稳定成长机会
- 和当前这个可见奖励,到底谁更值
2.3 当前构筑偏弱,想赌一个变量
还有一种情况,就是自己刚过上一个 blind 时已经有点强弩之末了... 现在再往后走,target score 更高,当前这套构筑看起来也不太稳定。
这个时候继续正常打,也不一定能救回来。反过来,如果当前给出的 tag 能让我拿到一个新的变量,那哪怕有点赌,我也愿意试。
所以这里的 skip,更像是:
正常走下去未必能活,不如主动换条当下更有希望的分支看看。
2.4 当前构筑与 skip 有联动
这个是我现在比较倾向认为更关键的一层。
因为有些 Joker 本身就会和 skip blind 这个动作产生联动。也就是说,玩家之所以跳过,并不只是因为 tag 本身,而是因为:
- 跳过能拿
tag - 同时又能触发当前
Joker的额外收益 - 这样一来,这一次
skip的整体价值就被放大了
纯粹的博弈,当然也会让人瞬间上头。但如果只是这次值不值,那次值不值,这种刺激未必能持续太久。
反而是那种:
- 当前构筑和
skip有关系 - 当前
tag又正好能接上 - 这次跳过后,后面整局的
shape都开始变了
所以我认为真正让 skip 机制更耐玩的,不只是单次博弈,而是它能和当前构筑发生联动,让一次阶段选择变成整局收益放大的节点。
三、Skip 与 Tag 在机制上的关系
3.1 skip 不是一个普通按钮
表面上看,skip blind 只是玩家在 small blind 或 big blind 前多了一个按钮。但如果只把它理解成"按钮点击",那其实会把这个机制看得太轻。
因为从交互层看,它当然只是一个动作;但从机制层看,它更像是一次 阶段决策结果。
👉 也就是说,玩家按下去的不是一个普通按钮,而是在这个阶段里做了一次分叉选择。
3.2 skip 真正替换掉的是什么
skip 替换掉的,不只是当前这一场对局。它真正替换掉的,其实是这一整个阶段原本会发生的那条正常路径。
正常情况下,玩家会:
- 进入当前
blind - 完成对局
- 进行结算
- 进入
shop - 再进入下一个阶段
而一旦选择 skip,玩家放弃掉的,其实是这次 正常推进后的稳定成长机会。
所以 skip 的重点并不是"少打一关",而是:
用当前一次阶段选择,换掉这一次正常成长机会。
3.3 Tag 不是 Blind 自己的属性
我现在会更倾向于理解成:
skip是一次阶段选择tag是这个选择之后发放出来的奖励结果
所以 tag 不是 blind 自带的挑战属性。它不是 blind 本身的一部分,而是玩家做出 skip 决策后,系统给出的 reward layer。
也正因为这样,我觉得:
blind configskip reward config
这两者应该拆开,而不是混在一起。
3.4 skip 不是永久改线,而是局部分叉
最开始我以为选择了 skip 就是改走另一条成长路线,但后来发现自己想错了。因为每次 small blind 和 big blind 前,玩家都只是面对一次 当前节点的选择:
- 这一次正常打
- 还是这一次直接
skip
它并不是说,从这一次开始,整局就永久切换成某种固定模式了。
所以更准确的说法应该是:
skip不像永久改线,更像主流程里不断出现的几个局部分叉口。
每一个分叉口都不会立刻决定整局,但会一点点把整局推向不同的结果。
3.5 为什么 skip + tag 会让整局变得更有意思
因为玩家每一次面对 skip 的时候,比较的都不是单一收益,而是:
- 当前这次稳定成长
- 和当前这个
tag能带来的另一种收益
短期看,它只是一次选择。但随着这些选择不断积累,最后整局的运行形态就会越来越不同。
所以 skip + tag 好玩的地方,不只是"这次奖励值不值",而是:
每一次阶段分叉,都会逐渐改写整局后续的可能性。
四、状态设计:Tag 如何进入当前游戏状态
4.1 初始化阶段,先生成当前 blind 对应的 skip reward
我倾向的做法是:在 initGame 的时候,先去读取独立的 skip reward config,然后通过 shuffle 打乱奖励顺序,再把打乱后的结果分配到对应的 small blind 和 big blind 上。
这样做最直接的原因,就是方便前端展示。因为前端只有在每个 ante 一开始就拿到当前 blind 的 skip reward,才能在玩家做出选择之前,先把:
- 当前
small blind跳过后会拿到什么 - 当前
big blind跳过后会拿到什么
直接展示出来。
这样玩家才是在"看见奖励之后"做决定,而不是先点了 skip,系统才临时告诉她会拿到什么。
4.2 挂载到 blindState 的,是奖励预览,不是玩家状态
这里我觉得特别容易混,所以要单独说清楚。
虽然我现在是想把 skip reward 在初始化阶段就挂载到 blindState 上,但它的含义并不是:
玩家一开局就已经拥有了这些
Tag
挂在 blindState 上,只是因为当前这个 blind 是可以被 skip 的,而这个 blind 一旦被 skip,系统会给出对应的奖励。
所以这一层更像是:
当前
blind的运行时奖励预览信息。
它存在的目的是:
- 让前端能提前展示
- 让玩家在选择前先看到奖励
- 让当前阶段的
skip机制真正成立
所以这时候奖励还属于 blind 的运行时信息,并不代表它已经进入玩家状态。
4.3 玩家真正获得 Tag 的时机,是执行 skip 之后
initGame 阶段做的事情,更多是:
- 读取配置
- 打乱
reward pool - 给当前可跳过
blind分配tag - 让前端能先展示出来
但这还不等于玩家已经拿到了奖励。真正把 tag 转成玩家当前状态的一刻,应该发生在:
玩家实际执行
skip blind之后。
也就是说,前面挂在 blindState 上的是"当前可见的 skip reward",只有当玩家真的点了 skip,这个奖励才会真正变成玩家当前获得的 Tag。
这个顺序对我来说更自然。因为它更符合整个交互逻辑:
- 系统先展示当前
blind的skip reward - 玩家根据当前构筑和局面做判断
- 玩家选择是否
skip - 如果真的
skip,对应奖励才正式进入玩家状态
这样之后,状态含义会清楚很多。
#mermaid-svg-T3H1qRnwfhslzW4c{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-T3H1qRnwfhslzW4c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T3H1qRnwfhslzW4c .error-icon{fill:#552222;}#mermaid-svg-T3H1qRnwfhslzW4c .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T3H1qRnwfhslzW4c .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c .marker.cross{stroke:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T3H1qRnwfhslzW4c p{margin:0;}#mermaid-svg-T3H1qRnwfhslzW4c .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster-label text{fill:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster-label span{color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster-label span p{background-color:transparent;}#mermaid-svg-T3H1qRnwfhslzW4c .label text,#mermaid-svg-T3H1qRnwfhslzW4c span{fill:#333;color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .node rect,#mermaid-svg-T3H1qRnwfhslzW4c .node circle,#mermaid-svg-T3H1qRnwfhslzW4c .node ellipse,#mermaid-svg-T3H1qRnwfhslzW4c .node polygon,#mermaid-svg-T3H1qRnwfhslzW4c .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .rough-node .label text,#mermaid-svg-T3H1qRnwfhslzW4c .node .label text,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape .label,#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape .label{text-anchor:middle;}#mermaid-svg-T3H1qRnwfhslzW4c .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .rough-node .label,#mermaid-svg-T3H1qRnwfhslzW4c .node .label,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape .label,#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape .label{text-align:center;}#mermaid-svg-T3H1qRnwfhslzW4c .node.clickable{cursor:pointer;}#mermaid-svg-T3H1qRnwfhslzW4c .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c .arrowheadPath{fill:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T3H1qRnwfhslzW4c .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T3H1qRnwfhslzW4c .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T3H1qRnwfhslzW4c .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T3H1qRnwfhslzW4c .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T3H1qRnwfhslzW4c .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T3H1qRnwfhslzW4c .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster text{fill:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster span{color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c 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-T3H1qRnwfhslzW4c .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T3H1qRnwfhslzW4c rect.text{fill:none;stroke-width:0;}#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape p,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape .label rect,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T3H1qRnwfhslzW4c .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T3H1qRnwfhslzW4c .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T3H1qRnwfhslzW4c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 即时生效
延迟生效
initGame
读取 Tag reward config
shuffle reward pool
为当前 ante 的
small / big blind 预分配 tag
写入 blindState.currentAnteConfig
前端展示当前可获得的 skip reward
玩家执行 skipBlind
服务端根据 blindType
读取当前 tagCode
对应 Tag 进入运行时处理流程
Tag 生效方式
直接修改当前状态
如 Boss Tag
写入 activeTags
如 Juggle Tag
继续推进后续流程
4.4 为什么不想把 reward 直接写死在 Blind 配置里
我更倾向让 skip reward config 单独存在,而不是直接写死在 blind config 里。
原因一方面是因为它们本来就不是一类东西。
blind描述的是挑战本身skip reward描述的是跳过之后能拿到的收益
它们虽然最终都会一起体现在当前 blindState 的运行时结果里,但来源不应该混在一起。
另一方面,如果 tag 直接写死在 blind 里,那每次重新开局的时候,对应 blind 的 skip reward 很容易就变成固定搭配。这样一来,玩家每次重新 initGame,当前 blind 的变化感就会弱很多。
所以我更倾向于:
- 配置层分开
- 运行时再组合
也就是:
tag不写死在blind config- 但在
initGame阶段,把当前blind实际抽到并分配到的结果挂到blindState
这样既保留了变化,也方便前端直接取值。
五、🎯 奖励触发与结算流程实现
前面几节主要是在说机制和状态关系。
从这一章开始,就进入具体实现部分:Tag 配置放在哪里、skipBlind 接口怎么处理、延迟生效的 Tag 怎么进入 activeTags,以及 blind 结束后这些临时状态应该如何清理。
这部分的目标不是把全部 Tag 都做完,而是先把一条完整链路跑通。
#mermaid-svg-Lf8c1zhKGPdbCsEA{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-Lf8c1zhKGPdbCsEA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Lf8c1zhKGPdbCsEA .error-icon{fill:#552222;}#mermaid-svg-Lf8c1zhKGPdbCsEA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Lf8c1zhKGPdbCsEA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .marker.cross{stroke:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Lf8c1zhKGPdbCsEA p{margin:0;}#mermaid-svg-Lf8c1zhKGPdbCsEA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster-label text{fill:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster-label span{color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster-label span p{background-color:transparent;}#mermaid-svg-Lf8c1zhKGPdbCsEA .label text,#mermaid-svg-Lf8c1zhKGPdbCsEA span{fill:#333;color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node rect,#mermaid-svg-Lf8c1zhKGPdbCsEA .node circle,#mermaid-svg-Lf8c1zhKGPdbCsEA .node ellipse,#mermaid-svg-Lf8c1zhKGPdbCsEA .node polygon,#mermaid-svg-Lf8c1zhKGPdbCsEA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .rough-node .label text,#mermaid-svg-Lf8c1zhKGPdbCsEA .node .label text,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape .label{text-anchor:middle;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .rough-node .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .node .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape .label{text-align:center;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node.clickable{cursor:pointer;}#mermaid-svg-Lf8c1zhKGPdbCsEA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .arrowheadPath{fill:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lf8c1zhKGPdbCsEA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster text{fill:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster span{color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA 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-Lf8c1zhKGPdbCsEA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA rect.text{fill:none;stroke-width:0;}#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape p,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape .label rect,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lf8c1zhKGPdbCsEA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Lf8c1zhKGPdbCsEA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Boss Tag
Juggle Tag
是
否
initGame
读取 skip reward config
shuffle Tag reward pool
为 small / big blind 预分配 tag
挂载到 currentAnteConfig
前端展示 skip reward
玩家执行 skipBlind
服务端根据 blindType 读取 tagCode
Tag 类型
立即改写当前 ante 的 Boss Blind 预设
推进到下一个 Blind
写入 activeTags
status = pending
startGame
是否存在 pending 的 Juggle Tag
当前局 handSize + 3
状态改为 applied
正常开始当前 Blind
Blind 结束
cleanupExpiredTags
移除已 applied 的临时 Tag
5.1 Tag 配置放到哪里?
每一次到"代码该放哪"的时候,都是一个很难的抉择,因为我目前还没有可以很快的分清边界线。
而目前项目中已有的两个配置:boss.config.ts 和 blind.config.ts 还被分别放置到了 poker/ 和 game/ 下,那一瞬间是恍惚的...
回看了一眼曾经的创作历程,也就是主线7的博文 ->《Boss Blind与特殊规则实现》,想翻找一下曾经把 boss.config.ts 放到 poker/ 下是因为啥,但当时没写???
但我也没再纠结,干脆顺手把 boss.config.ts 挪到了 game/ 下,也把本次新增的 tag.config.ts 一起放了进去。
5.1.1 当前配置相关的目录结构
当前关于配置的目录结构:
src/game/
blind.config.ts
boss.config.ts
tag.config.ts
game.types.ts
game.constants.ts
src/poker/
poker.constants.ts
poker.types.ts
poker.service.ts
5.1.2 tag.config 代码示例
tag目前只写了2个特殊实现,当前做的只是把skip blind和tag reward这条第一条链路跑通。先搭好框架,为后续扩展做准备。- 这里并不是在定义完整的
Tag池,而是先选两个最有代表性的Tag,分别演示"即时生效型"和"延迟生效型"的处理方式。
ts
export const TAG_CODE = {
BOSS_TAG: 201,
JUGGLE_TAG: 202,
} as const;
export const TAG_CONFIG = {
[TAG_CODE.BOSS_TAG]: {
code: TAG_CODE.BOSS_TAG,
name: "Boss Tag",
description: "Rerolls the next Boss Blind",
},
[TAG_CODE.JUGGLE_TAG]: {
code: TAG_CODE.JUGGLE_TAG,
name: "Juggle Tag",
description: "+3 Hand Size for the next round only",
},
} as const;
export type TagCode = (typeof TAG_CODE)[keyof typeof TAG_CODE];
5.1.3 tag.config 的使用
在配置生成的过程中,tag.config.ts 我们延续了 boss.config.ts 的结构。
在实际代码使用的过程中,获取到配置后延续在洗牌时的 Fisher-Yates shuffle 算法。
这里生成的是当前运行中每个可跳过 blind 的奖励预览,真正执行 skipBlind 后,对应奖励才会进入玩家运行时状态。
ts
const TOTAL_ANTE_COUNT = Object.keys(BLIND_SCORE_CONFIG).length;
const totalSkipBlindCount = TOTAL_ANTE_COUNT * 2;
for (let i = 0; i < totalSkipBlindCount; i++) {
allTagCodes.push(baseTagCodeList[i % baseTagCodeList.length]);
}
tagAssignmentsByPlayer = this.shuffleConfig(allTagCodes);
5.2 skipBlind
5.2.1 为什么单独做 skipBlind 接口
当前对外的接口有:
initGame游戏初始化startGame开始游戏selectCards选择出牌/弃牌并拿到决策结果
本次的跳过 Blind 并未在原有的接口扩展,而是重新创建了一个新的接口 skipBlind。
因为从行为上看,skipBlind 不是 startGame 的一种小分支,而是一个独立决策。单独接口会比塞进别的接口里更清楚。
5.2.2 skipBlind 代码实现
在代码实现的过程中:
TAG_CODE.BOSS_TAG:直接改写当前ante下boss blind的预设配置,因此不需要额外维护延迟状态。TAG_CODE.JUGGLE_TAG:不在当前立即生效,而是先暂存到activeTags中,等到下一局startGame时再触发,并在该局结束后清理。
一开始也考虑过让前端把 tagCode 一起传回来,但后面还是收回到了服务端内部。因为 tagCode 在 AnteConfig 里已经提前分配好了,skip 时由后端根据当前 blindType 自行读取会更安全,也能避免把本来属于服务端状态的信息再次暴露给前端输入。
ts
public skipBlind(blindType: SkippableBlindType, round: number, playerId: string): GameActionResult {
...
// Rerolls the next Boss Blind
if (tagCode === TAG_CODE.BOSS_TAG) {
const currentBossCode = blindState.currentAnteConfig.boss.code;
//在重掷 Boss Blind 时,还额外排除了当前已经存在的 boss code,避免重掷后结果不变,导致玩家感知不到这个 Tag 已经生效。
const bossBlindCodeList: BossBlindCode[] = Object.values(BOSS_BLIND_CODE).filter(
(code) => code !== currentBossCode,
);
const newBossBlindCode = bossBlindCodeList[Math.floor(Math.random() * bossBlindCodeList.length)];
//这里对 Boss Tag 的处理,本质上不是"把 Tag 挂起来等待未来触发",而是"立即改写当前 ante 下尚未开始的 Boss Blind 预设"。
blindState.currentAnteConfig.boss.code = newBossBlindCode;
blindState.currentAnteConfig.boss.name = BOSS_BLIND_CONFIG[newBossBlindCode].name;
// this.logger.log(
// `Player ${playerId} used ${tagConfig.name} to reroll Boss Blind. New Boss Blind: ${blindState.currentAnteConfig.boss.name}`,
// );
} else if (tagCode == TAG_CODE.JUGGLE_TAG) {
this.activeTags[playerId].push({
code: TAG_CODE.JUGGLE_TAG,
status: "pending",
});
}
...
}
5.2.3 为什么需要 activeTags
一开始我只想把 tagCode 存起来,也就是说 this.activeTags[playerId].push(tagCode),但后来实际运行的时候发现这不够。
因为像 Juggle Tag 这种不是当前立即结算,而是在下一局 startGame 时生效的效果,可能未来不止一个,并且仅靠一个 tagCode 无法判断它当前是否已经应用,是否还需要清理。
所以这里额外的引入了 activeTags,用来维护玩家当前还处于运行时生效链路中的 Tag 状态。
ts
export type PlayerActiveTag = {
code: TagCode;
status: "pending" | "applied";
};
private readonly activeTags: Record<string, PlayerActiveTag[]> = {};
5.2.4 Juggle Tag 如何在下一局生效
对于延迟触发的 JUGGLE_TAG, 在实际逻辑中,涉及到了 startGame 的判断和结束时的清除。
在 startGame 时,先检查当前玩家是否存在状态为 pending 的 JUGGLE_TAG。如果存在,则只在当前局的临时 handSize 上追加数值,并将对应 Tag 的状态更新为 applied。
这里尽量不直接改动 playerState.handSize 这种基础状态,而是只在当前局的临时计算值上追加效果。这样可以避免后续流程里出现不必要的状态污染。
ts
public startGame(playerId: string): DealResult {
...
let handSize: number = playerState.handSize;
if (this.activeTags[playerId]?.some((tag) => tag.code === TAG_CODE.JUGGLE_TAG && tag.status === "pending")) {
handSize += 3;
this.activeTags[playerId].forEach((tag) => {
if (tag.code === TAG_CODE.JUGGLE_TAG && tag.status === "pending") {
tag.status = "applied";
}
});
}
...
}
5.2.5 为什么还需要 cleanupExpiredTags
像 Juggle Tag 这种只对下一局生效的临时效果,并不能在 startGame 时应用完就彻底结束。因为它还需要经历一个"本轮已生效,但尚未清理"的状态。
所以在当前 blind 结束后的推进逻辑里,我额外增加了 cleanupExpiredTags,用于移除已经完成生命周期的临时 Tag。
ts
private cleanupExpiredTags(playerId: string): void {
this.activeTags[playerId] = (this.activeTags[playerId] ?? []).filter(
(tag) => !(tag.code === TAG_CODE.JUGGLE_TAG && tag.status === "applied"),
);
}
5.2.6 blind 结束后的推进逻辑为什么要拆出去
这次 skipBlind 的返回结构并没有重新定义一套新类型,而是继续沿用了统一的 GameActionResult(原 SelectCardsResult)。
这样前端处理流程时,不需要额外再为"跳过 blind"单独维护一套全新的结果结构。
与此同时,随着 skipBlind、Tag cleanup、blind settlement 这些逻辑逐渐变多,原本集中写在 buildActionResult 里的 blind 结束处理开始变得过重。
所以这里把 blind 结束后的推进逻辑额外拆成了 resolveProgressAfterBlind,让"状态推进"和"结果组装"至少先做一层分离。
ts
private resolveProgressAfterBlind(gameState: GameState, blindStates: BlindState, progress: Progress): void {
const result = blindStates.currentBlindScore >= blindStates.targetScore ? "WIN" : "LOSE";
progress.settlement = {
finalScore: blindStates.currentBlindScore,
targetScore: blindStates.targetScore,
result: result,
};
if (result === "LOSE") {
progress.gameOver = true;
this.clearPlayerRuntimeState(gameState.playerId);
} else {
const nextProgress = this.getNextBlindProgress(gameState);
progress.nextBlindConfig = nextProgress.nextBlindConfig;
if (nextProgress.nextAnteConfig) {
progress.nextAnteConfig = nextProgress.nextAnteConfig;
}
this.advanceToNextBlind(gameState, nextProgress);
this.cleanupExpiredTags(gameState.playerId);
}
}
同时,为了避免 gameover 或重新初始化时残留旧的运行时数据,这里也额外增加了 clearPlayerRuntimeState,统一清理当前玩家的运行时状态。
ts
private clearPlayerRuntimeState(playerId: string): void {
delete this.gameStates[playerId];
delete this.activeTags[playerId];
delete this.bossBlindAssignments[playerId];
delete this.tagAssignments[playerId];
}
#mermaid-svg-6e7fI6E1OJqQZAoB{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-6e7fI6E1OJqQZAoB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6e7fI6E1OJqQZAoB .error-icon{fill:#552222;}#mermaid-svg-6e7fI6E1OJqQZAoB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6e7fI6E1OJqQZAoB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB .marker.cross{stroke:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6e7fI6E1OJqQZAoB p{margin:0;}#mermaid-svg-6e7fI6E1OJqQZAoB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster-label text{fill:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster-label span{color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster-label span p{background-color:transparent;}#mermaid-svg-6e7fI6E1OJqQZAoB .label text,#mermaid-svg-6e7fI6E1OJqQZAoB span{fill:#333;color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .node rect,#mermaid-svg-6e7fI6E1OJqQZAoB .node circle,#mermaid-svg-6e7fI6E1OJqQZAoB .node ellipse,#mermaid-svg-6e7fI6E1OJqQZAoB .node polygon,#mermaid-svg-6e7fI6E1OJqQZAoB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .rough-node .label text,#mermaid-svg-6e7fI6E1OJqQZAoB .node .label text,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape .label,#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape .label{text-anchor:middle;}#mermaid-svg-6e7fI6E1OJqQZAoB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .rough-node .label,#mermaid-svg-6e7fI6E1OJqQZAoB .node .label,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape .label,#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape .label{text-align:center;}#mermaid-svg-6e7fI6E1OJqQZAoB .node.clickable{cursor:pointer;}#mermaid-svg-6e7fI6E1OJqQZAoB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB .arrowheadPath{fill:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6e7fI6E1OJqQZAoB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6e7fI6E1OJqQZAoB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6e7fI6E1OJqQZAoB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6e7fI6E1OJqQZAoB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6e7fI6E1OJqQZAoB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster text{fill:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster span{color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB 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-6e7fI6E1OJqQZAoB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB rect.text{fill:none;stroke-width:0;}#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape p,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape .label rect,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6e7fI6E1OJqQZAoB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6e7fI6E1OJqQZAoB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6e7fI6E1OJqQZAoB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重构前
buildActionResult
组装返回结果
判断 blind 是否结束
结算 WIN / LOSE
推进到下一个 Blind
清理临时 Tag
game over 时清理运行时状态
重构后
WIN
LOSE
buildActionResult
组装返回结果
blindOver 时调用
resolveProgressAfterBlind
结算 WIN / LOSE
advanceToNextBlind
cleanupExpiredTags
clearPlayerRuntimeState
5.2.7 当前链路验证结果
| 验证场景 | 操作 | 预期结果 |
|---|---|---|
| 初始化奖励预览 | 调用 initGame |
small blind / big blind 都会拿到预分配的 skip reward |
| 跳过 Small Blind | 调用 skipBlind('small') |
服务端根据当前 blindType 读取对应 tagCode,不依赖前端传入 |
| Boss Tag 生效 | 当前 tagCode 为 BOSS_TAG |
当前 ante 下尚未开始的 Boss Blind 会被重新分配 |
| Juggle Tag 延迟生效 | 当前 tagCode 为 JUGGLE_TAG |
先写入 activeTags,状态为 pending |
| 下一局开始 | 调用 startGame |
如果存在 pending 的 Juggle Tag,当前局 handSize + 3,并将状态改为 applied |
| 当前局结束 | blind 结算完成 |
cleanupExpiredTags 清理已经 applied 的临时 Tag |
也就是说,目前这版验证的是:
initGame 预览奖励 -> skipBlind 获得 Tag -> Tag 生效 -> blind 结束清理这条最小闭环。
六、当前实现边界:先支持影响 Blind 的 Tag
6.1 为什么现在不适合把 tag 做得太大
虽然理论上 tag 可以影响很多系统,但当前这个项目阶段,并不适合一开始就把它做成一个特别大的通用系统。
因为现在真正比较清楚、已经成型的,还主要是 blind 这一块。而像:shop、economy、joker engine这些要么还没开始,要么还没有稳定结构。
如果现在一上来就想把所有 tag 都抽成统一规则、统一生命周期、统一结算入口,那很容易先把抽象做大,但实际没有足够的落点去承接。写起来会很热闹,落到代码里反而会越来越虚。
所以我最后还是决定,先把边界收住,先解决眼前真正已经成型的问题。
6.2 为什么先按"影响哪个系统"来分类
一开始我也想过,是不是应该先按"立即生效 / 延迟生效 / 持续生效"这种方式去分。
但后来想了一圈,还是觉得当前阶段更适合先按:
tag首先影响哪个系统
来分类。因为在当前项目里,先搞清楚"它归谁管",其实比先搞清楚"它持续多久"更重要。
也就是先知道:
- 它首先作用于
blind - 还是首先作用于
player - 或者以后再作用于
shop
这样分,会更贴近我现在项目里的模块边界,也更容易决定代码到底该往哪边挂。
6.3 为什么不是一次性支持所有的 tag
因为现在项目里最成熟的系统就是 blind。而且我们前面聊下来,也已经明显感觉到:
- 有些
tag首先影响的是玩家 - 有些
tag首先影响的是blind
所以现在最稳的做法,不是一次性支持所有可能,当前实现仍然优先围绕 blind 流程展开,但在具体 Tag 选择上,也加入了一个作用于玩家临时状态的代表性 Tag(Juggle Tag),用来验证延迟生效链路。
6.4 在 blind 内,为什么我最先想到的是 target / rule
当我去想"影响 blind 的 tag"时,最先有感觉的,其实是:
target scoreblind ruleboss blind的特殊优势或限制
因为这些东西:
- 当前项目里已经有结构
- 当前
blindState里本来就有落点 - 也最符合
tag改变当前阶段结果这件事
相比之下,像出牌次数 +1、弃牌次数 +1、手牌上限 +1 这些效果,虽然也会影响对局结果,但它们更像是在改玩家本身,而不是直接改 blind。
不过在真正实现时,我额外保留了一个作用于玩家临时状态的 Juggle Tag,这样一来,不仅能验证作用于 blind 的即时效果,也能顺手把延迟生效型 tag 的处理链路一起跑通。
6.5 当前阶段的实现策略:先跑通最小链路
所以当前阶段,我并不是要一次性把全部 tag 都设计完,而是先把第一条最小链路跑通:
- 先有独立的
skip reward config - 先能在
initGame阶段生成并挂到blindState - 前端先能看到当前可获得的
skip reward - 玩家
skip后,再把对应tag接入当前运行时状态 - 最后在对应时机触发
effect
只要这一条最小链路先跑通,后面再往别的系统扩,才会更稳。
也就是说,这一篇真正完成的不是"完整 Tag 系统",而是:
**
Skip Blind + Tag Reward的第一条可运行链路。**
七、总结
7.1 当前阶段完成内容
这一篇里,我主要完成了 skip blind 与 Tag reward 这条基础链路的落地。
当前阶段主要实现了:
skip blind机制的设计拆解skip与tag的关系梳理Tag奖励预览与玩家实际获得状态的区分tag.config.ts的基础配置结构small blind/big blind的tag预分配skipBlind独立接口实现Boss Tag与Juggle Tag两种代表性Tag的基础接入
7.2 这一阶段真正难的地方
这篇里最麻烦的,其实不是多写了几个接口,而是要先把状态和生命周期关系理清楚。
比如:
Tag什么时候生成- 玩家什么时候才算真正获得
Tag - 即时生效和延迟生效怎么区分
- 运行时状态该挂在哪里、什么时候清理
这些问题如果没想清楚,代码写着写着就会开始打架。
7.3 当前并不是完整 Tag 系统
这一篇并不是一次性把全部 Tag 都实现完了,而是先围绕当前已经比较成熟的 blind 流程,把最小闭环跑通。
所以当前真正完成的,其实是:
Skip Blind + Tag Reward的第一条可运行链路
7.4 下一步会继续补什么
等这条链路稳定下来后,后面再继续补:
- 更多真实
Tag类型 - 和
shop、economy相关的奖励效果 - 和
Joker更复杂的联动 - 更完整的奖励池与分配策略
至少到这里,第二阶段里和 skip blind 相关的核心基础已经打下来了,后面再继续扩,心里会更有底一些。