从0到1实现 Balatro 游戏后端(3):洗牌、发牌与服务端牌堆状态管理

35 阅读7分钟

本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统。涵盖规则实现、架构设计、WebSocket 通信、模块拆分以及工程化演进。

  • 项目地址:balatro-realtime-backend
    • 📌 对应代码版本:feat: add deck shuffling and server-side dealing state(2026-05-06)
    • ⚠️ 本文基于该 commit 讲解,仓库代码可能已更新

📚 系列文章:

(1)项目规划与牌型判断实现
(2)NestJS框架搭建与项目结构设计
(3)洗牌、发牌与服务端牌堆状态管理(当前)
(4)出牌 / 弃牌 / 补牌
(5)得分计算与回合结算
(6)Blind关卡状态设计与回合推进实现(开发中)


✅前两篇里,我已经完成了:

  • 牌型判断
  • NestJS 架构搭建
  • Game / Poker 模块拆分
  • WebSocket 接入

但到这里,其实还有一个问题没有真正解决:游戏还没有“状态”。之前的逻辑,本质上都还是: 输入 -> 计算 -> 返回结果

比如:

  • 输入牌
  • 判断牌型
  • 返回结果

这类逻辑虽然已经可以运行,但它更像:“静态规则计算”,而不是:“真正运行中的游戏”。

因为真实游戏一定会涉及:

  • 当前牌堆剩余多少牌
  • 玩家当前手牌是什么
  • 当前回合进行到哪里
  • 剩余多少次出牌机会

也就是说:

游戏必须开始“记住状态”。

所以这一篇,我开始正式进入:

🎯 服务端游戏状态管理阶段。也就是让服务器“记住游戏”


✅本篇实现了什么

这一篇主要实现:

  • 服务端牌堆状态管理
  • 洗牌逻辑
  • 发牌流程
  • deck / hand 状态维护

重点会解决:

  • 服务端如何维护一副完整扑克牌
  • 如何实现可控洗牌
  • 发牌过程中如何保证状态一致
  • 如何设计 deck 数据结构

这一阶段完成后:

项目开始从“规则判断”进入“动态游戏状态管理”。


一、为什么要由服务端控制发牌

一开始我先问了自己一个问题:

如果让客户端主导发牌,会发生什么?

很快脑子里就冒出了很多问题:

  • 掉线之后还能恢复当前牌局么?
  • 是否更容易作弊?
  • 游戏过程能不能存档?
  • 能不能做对局复盘?
  • 网络波动后状态还能衔接么?

这些问题想了一圈之后,我发现:

把状态交给客户端,风险太大了。

因为:

  • 状态同步
  • 数据校验
  • 游戏存档
  • 逻辑计算

这些本来就是服务端更擅长的事情。

所以这一阶段,我明确了一件事:

👉 牌堆状态必须由服务端维护。

而不是交给客户端。


二、什么是“牌堆状态”

当前阶段,服务器会维护两类核心状态:

  • deck:当前剩余牌堆

  • hand:当前玩家手牌


其中:

  • deck:只保存还未发出的牌

  • hand:保存已经发给玩家的牌


当前结构:

interface Card {
    rank: number;
    suit: Suit;
}

Deck = {
    deck: Card[],
    hand: Card[],
}

三、洗牌实现

这里一开始其实先确定了一件事:

当前游戏到底是“有限牌堆”还是“无限牌堆”?

而 Balatro 很明显属于:

  • 固定 52 张牌
  • 固定牌面
  • 固定牌堆数量

并且:每张牌出现的位置概率应该完全相等


所以这里最终选择:Fisher-Yates shuffle(官方解释: “The Fisher–Yates shuffle is an algorithm for shuffling a finite sequence. The algorithm produces an unbiased permutation: every permutation is equally likely.”)

核心其实就两个词:

  • 有限序列
  • 等概率

刚好符合当前需求。


为什么不用 sort 随机

这里其实还有一种非常常见的写法:

deck.sort(() => Math.random() - 0.5)

但这种方式:并不能保证真正的“等概率随机”。所以并不适合作为真正洗牌算法。

而 Fisher-Yates 的核心优势就在:每次交换概率完全均匀


四、发牌逻辑

1. 发牌代码

核心逻辑:

const hand = this.serializeCards(deck.splice(0, handSize));

2. 为什么使用 splice

这里一开始其实有考虑:slice,但后面还是改成了:splice

原因很简单:slice 本质上是:copy原数组不会修改

但发牌真正需要的是:发出去之后,deck 本身就要减少

也就是说:发牌的本质是“修改状态”


如果使用 slice,则还需要:

  • remainDeck
  • 再重新赋值

无论步骤还是状态维护,都会更麻烦。

所以最后还是选择 deck.splice(0, handSize) 直接修改原牌堆。


3. 发牌真正的本质是什么

很多时候会把发牌理解成:

从数组里取几张牌。

但在服务端游戏设计里:

发牌本质上其实是“状态变化”。


具体表现为:

  • deck 减少
  • hand 增加
  • 状态被记录

所以这里真正重要的不是:取牌,而是:状态更新


五、当前阶段的状态设计

从这一阶段开始:

服务器开始真正维护“运行中的游戏状态”。


当前阶段先实现基础版:

type GameState = {
    playerId: string;

    deck: Card[]; // remain deck
    hand: string[]; // current hand

    playsLeft: number;
    discardsLeft: number;

    round: number;

    score: number;
};

当前只是先把:GameState 基础结构写出来。暂时先放在:poker.types.ts 后面可能会继续调整位置和结构。


六、与当前结构的关系

当前阶段已经存在:

  • PokerService
  • GameService

其中:

  • PokerService 负责:“牌”相关规则与工具能力

  • GameService 负责:游戏流程与状态


目前因为还没有:room,player 等概念。

所以很多状态还暂时放在:poker 里。

后面随着复杂度增加:这些状态还会继续拆分。


为什么现在不继续细拆

因为我发现:

项目初期过度拆分,反而容易边界混乱。

现阶段比起“绝对正确架构”,更重要的是:

系统能稳定推进。


七、代码实现

1. 当前发牌调用链

Client
  ↓
GameGateway
  ↓
GameService
  ↓
PokerService.dealCards()
  ↓
修改服务器牌堆状态
  ↓
返回 hand

2. 洗牌实现

private initPlayerState(playerId: string): GameState {
    const deck = this.shuffleDeck(this.getBaseDeck());

    this.gameStates[playerId] = {
        playerId: playerId,
        deck: deck,
        hand: [],
        playsLeft: 5,
        discardsLeft: 3,
        round: 1,
        score: 0,
    };

    Logger.log(`-${playerId}- initPlayerState: ${JSON.stringify(this.gameStates[playerId])}`);

    return this.gameStates[playerId];
}

private shuffleDeck(deck: Card[]): Card[] {
    for (let i = deck.length - 1; i > 0; i--) {
        const randomNum = Math.floor(Math.random() * (i + 1));

        [deck[i], deck[randomNum]] = [deck[randomNum], deck[i]];
    }

    return deck;
}

3. 发牌实现

let playerState: GameState;

playerState = this.initPlayerState(playerId);

const deck = playerState.deck;

const hand = this.serializeCards(deck.splice(0, handSize));

这里:

  • 服务端内部使用 Card[]
  • 返回给前端统一转:string[]

转换逻辑:

private serializeCards(cards: Card[]): string[] {
    return cards.map((card) => {
        const rank = NUMBER_TO_RANK_MAP[card.rank] ?? card.rank;
        return `${rank}${card.suit}`;
    });
}

4. dealCards

当前设计中:round === 1 表示:新游戏开始。

这一阶段服务器会:

  • 创建新牌堆
  • 洗牌
  • 初始化玩家状态
  • 保存 GameState

public dealCards(data: DealCardsInput): DealResult {
    const { playerId, handSize, round } = data;

    let playerState: GameState;

    if (round === 1) {
        playerState = this.gameStates[playerId];

        if (!playerState) {
            playerState = this.initPlayerState(playerId);
        }
    } else {
        playerState = this.gameStates[playerId];
    }

    if (!playerState) {
        return { hand: [], remainingDeckCount: 0, playsLeft: 0 };
    }

    const deck = playerState.deck;

    const hand = this.serializeCards(deck.splice(0, handSize));

    playerState.hand = hand;
    playerState.deck = deck;
    playerState.round = round;

    if (round != 1) playerState.playsLeft--;

    return {
        remainingDeckCount: deck.length,
        playsLeft: playerState.playsLeft,
        hand
    };
}

八、总结

1. 当前阶段完成内容

  • ✔ 牌堆状态管理
  • ✔ 洗牌逻辑
  • ✔ 发牌流程

项目开始从:规则判断, 进入:游戏状态控制


2. 这一阶段真正的变化

前两篇更多还是:输入 -> 计算 -> 返回结果

而从这一阶段服务器开始真正维护:

  • 当前剩余牌堆
  • 当前手牌
  • 当前回合
  • 剩余操作次数

也就是说:

项目开始从“功能实现”进入“游戏引擎状态管理”。


3. 当前阶段的几个设计原则

这一阶段我其实慢慢开始明确几个原则:

  • 状态必须由服务端控制
  • 游戏逻辑本质是“状态变化”
  • 初期优先保证流程可运行
  • 架构应该随着复杂度逐步演进

这些东西后面会在回合系统、出牌、补牌、结算里会反复出现。


4. 下一步

当前阶段服务器已经可以维护:

  • deck
  • hand
  • 基础 GameState

下一步会开始进入:

  • 出牌
  • 弃牌
  • 补牌
  • 回合推进

也意味着:GameState 会逐渐成为整个游戏后端的核心。