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

32 阅读12分钟

本系列记录我从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。

  • balatro-realtime-backend -> GitHub地址GitCode地址
    • 📌 本文对应代码分支:origin/feature/blind-level-system
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。

📚 系列文章:

(1)🔗 项目规划与牌型判断实现
(2)🔗 NestJS框架搭建与项目结构设计
(3)🔗 洗牌、发牌与服务端牌堆状态管理
(4)🔗 玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计
(5)🔗 得分计算与单局结算流程实现
(6)Blind关卡状态设计与回合推进实现 (当前)✅
(7)Boss Blind与特殊规则实现
(8)跳过Blind奖励与风险机制设计(开发中)


✅ 本篇实现了什么

本篇开始正式引入 Blind 关卡系统,并让整个项目从“单局流程”逐渐进入“关卡生命周期”。

当前阶段主要完成了:

  • Blind / Ante / Round 状态设计
  • GameState 结构拆分
  • BlindState 生命周期引入
  • Blind 配置结构实现
  • 阶段目标分读取
  • Blind 结算与回合推进
  • blindOvergameOver 生命周期拆分
  • 胜利保留进度
  • 失败重置游戏状态

到这里为止,整个项目已经开始具备:small blind -> big blind -> boss blind -> next ante 这样的阶段推进能力。

flowchart LR

    subgraph Ante1["Ante 1"]
        A1["Small Blind"]
        A2["Big Blind"]
        A3["Boss Blind"]

        A1 --> A2 --> A3
    end

    subgraph Ante2["Ante 2"]
        B1["Small Blind"]
        B2["Big Blind"]
        B3["Boss Blind"]

        B1 --> B2 --> B3
    end

    subgraph Ante3["Ante 3"]
        C1["Small Blind"]
        C2["Big Blind"]
        C3["Boss Blind"]

        C1 --> C2 --> C3
    end

    A3 --> B1
    B3 --> C1

一、为什么要引入 Blind 系统

前五篇里,我终于把单局游戏流程跑通了。

玩家已经可以:

  • 发牌
  • 出牌
  • 弃牌
  • 补牌
  • 得分结算

但很快我发现一个问题:

如果游戏永远停留在:开始 → 出牌 → 结算 → 结束 , 那么它始终只是一个“单局游戏”。

Balatro 真正有意思的地方,其实并不只是牌型和分数。

而是:

small blind
    ↓
big blind
    ↓
boss blind
    ↓
next 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 做了一次结构拆分。拆分后的顶层结构如下:

export type GameState = {
  playerId: string;

  playerState: PlayerState;
  blindState: BlindState;

  gameStatus: "playing" | "finished";
};

这里我把状态分为了两类。

3.1 playerState:玩家资源状态

第一类是 playerState,它负责保存玩家当前持有的资源状态:

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,它负责保存当前关卡状态:

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 blindbig blind 还是 boss blind
  • 当前 Blind 的目标分是多少?
  • 当前 Blind 已经累计了多少分?

4. GameState 不再是“巨型垃圾桶对象”

拆分后 GameState 不再直接承载所有具体字段,而是变成一个顶层聚合状态:

flowchart LR

    subgraph AFTER["重构后"]
        B["GameState"]

        B --> C["PlayerState"]
        B --> D["BlindState"]
        B --> E["gameStatus"]

        C --> C1["deck"]
        C --> C2["hand"]
        C --> C3["playsLeft"]
        C --> C4["discardsLeft"]
        C --> C5["handSize"]
        C --> C6["currentActionScore"]

        D --> D1["round"]
        D --> D2["ante"]
        D --> D3["blindType"]
        D --> D4["targetScore"]
        D --> D5["currentBlindScore"]
    end
    subgraph BEFORE["重构前"]
        A["GameState"]

        A --> A1["deck"]
        A --> A2["hand"]
        A --> A3["playsLeft"]
        A --> A4["discardsLeft"]
        A --> A5["round"]
        A --> A6["ante"]
        A --> A7["blindType"]
        A --> A8["targetScore"]
        A --> A9["currentBlindScore"]
        A --> A10["gameStatus"]
    end

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 对应的配置,目前都只是暂定的。

因为当前项目还没有加入 JokerModifier、特殊牌等额外加成系统,所以整体分数成长还比较有限。因此这一阶段的重点,并不是去精确调整数值。而是先把生命周期结构先跑通。

Blind 配置 -> Blind 获取 -> Blind 结算 -> Blind 推进

1.1 blind.config.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 的初始化流程更干净。

    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

if (action == SELECT_CARD_ACTION.PLAY) {
  blindState.currentBlindScore += baseScore * multiplier;
  playerState.currentActionScore = baseScore * multiplier;
} else {
  playerState.currentActionScore = 0;
}

这里的 currentActionScore 表示当前这一次操作实际获得的分数。而 currentBlindScore 则表示当前整个 Blind 已经累计的阶段分数。

flowchart TD
    A[玩家选择操作] --> B{action 是 play?}

    B -->|是| C[计算本次得分]
    C --> D[currentBlindScore 累加]
    D --> E{达到 targetScore?}

    B -->|否 discard| F[换牌<br/>currentActionScore = 0]
    F --> G[继续当前 Blind]

    E -->|是| H[blindOver = true]
    H --> I[保留阶段进度]
    I --> J[下一次 startGame]
    J --> K[round + 1]
    K --> L[进入下一个 Blind]

    E -->|否| M{playsLeft <= 0?}
    M -->|是| N[gameOver = true]
    N --> O[delete gameState]
    O --> P[失败后重新开始]

    M -->|否| G

3. 判断当前 Blind 是否结束

在当前的阶段,我们判断 Blind 是否结束,只根据两点

  • 玩家是否已经耗尽所有出牌次数
  • 当前阶段是否已经达到目标分数

任意一个条件满足,都说明当前 Blind 生命周期已经结束。

    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 也增加了部分阶段状态返回:

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 最有意思的地方,从来不是分数,而是规则。

当某些牌型失效、某些操作被限制之后,玩家原本习惯的出牌方式可能会被彻底打乱。

而对于后端来说,这意味着游戏规则开始不再固定。

下一篇就准备来解决这个问题。