从0到1实现 Balatro 游戏后端(1):项目规划与牌型判断实现

67 阅读5分钟

本文是一个长期工程实践系列的开篇,目标是从零实现一个 Balatro 风格的游戏后端,并逐步演进为一个具备完整工程能力的实时服务系统。

  • 项目地址:balatro-realtime-backend
  • 📌 对应代码版本:init project with poker hand evaluator and unit test(2026-03-12)
  • ⚠️ 注意:仓库代码会持续迭代,本文基于指定 commit 进行讲解

一开始我只是想写一个“牌型判断”的小功能。

但写着写着,我发现一个问题:

👉 如果只是把逻辑写出来,这个项目是没有任何工程价值的。

因为现实中的后端系统,从来不是一堆函数拼起来的,而是:

  • 有状态
  • 有流程
  • 有边界
  • 有扩展能力

于是我决定做一件更难的事:

👉 从0开始,做一个“真正可运行”的实时游戏后端。

这个系列,就是这个过程的完整记录。


📚 系列文章:

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


一、为什么做这个项目?

这个项目的目标,并不是写一个“算法 demo”,也不是简单复刻游戏逻辑。

而是通过一个真实可运行的卡牌游戏后端,系统性练习后端工程能力:

  • 状态机设计
  • WebSocket 实时通信
  • Redis 缓存分层
  • MySQL 持久化
  • 游戏状态恢复
  • Docker 化部署
  • 单元测试与日志系统
  • 可扩展规则系统(Modifier / Effect)

为什么选 Balatro?

因为它刚好具备几个非常适合工程拆解的特点:

  • 回合制状态清晰
  • 规则复杂但可拆分
  • 卡牌效果天然可扩展
  • 非常适合逐步工程化演进

二、本系列规划

这个项目不会“一步到位”,而是按阶段逐步推进:

阶段一:单局游戏流程

目标:做出一个可运行的游戏后端

包括:

  • Game Engine
  • 回合状态管理
  • 发牌 / 出牌 / 弃牌 / 补牌
  • 得分计算
  • 回合结算
  • WebSocket 主流程

当前进度:

✔ 已完成:牌型判断


阶段二:关卡与盲注系统

  • 小盲 / 大盲 / Boss
  • 目标分数递增
  • 回合推进
  • 局结束判断

阶段三:持久化与缓存

  • MySQL
  • Redis
  • 游戏恢复
  • 对局记录
  • 状态分层设计

阶段四:工程化

  • Docker Compose
  • 日志系统
  • 单元测试
  • 架构设计
  • README 完善

阶段五:效果系统(核心)

  • Joker 系统
  • Modifier
  • EventBus
  • Effect 扩展机制

阶段六:商店系统

  • 奖励池
  • 权重随机
  • 刷新 / 购买

阶段七:特殊卡牌

  • 塔罗牌 / 星球牌 / 幻灵牌

阶段八:扩展与AI

  • 房间系统
  • 排行榜
  • AI 对局
  • Agent 扩展

三、技术栈设计

当前技术规划:

  • Node.js
  • TypeScript
  • NestJS
  • WebSocket
  • Jest
  • Redis
  • MySQL
  • Docker

前期只做“纯逻辑”,后期逐步接入框架与工程体系。


四、牌型判断实现

牌型判断,是整个系统的基础能力。

后面所有内容(得分、效果系统、Modifier)都会依赖它。


1. 目标

输入:1~5 张牌
输出:牌型等级(数值)

支持牌型:

牌型分值
straightFlush9
fourOfAKind8
fullHouse7
flush6
straight5
threeOfAKind4
twoPair3
onePair2
highCard1

2. 规则说明

点数:

  • 2 ~ 10, J, Q, K, A
  • A 默认 14
  • 特殊顺子:A2345 → A 当 1

花色:

  • H ♥️
  • S ♠️
  • D ♦️
  • C ♣️

3. 核心实现思路

这里没有选择“暴力枚举所有牌型”,而是拆成三步:

  1. 解析牌
  2. 统计花色 / 点数
  3. 组合判断牌型

这样做的原因是:

👉 后面需要支持 Modifier / Effect 系统,这种结构更容易扩展和插入规则。

关键点:

  • 使用 Set 判断顺子是否重复
  • 使用 rankCount 判断对子 / 三条 / 四条
  • 使用排序判断连续性
  • 特殊处理 A2345

4. 核心代码

function getCardType(cards: string[]): number {
    const userCard = parseCard(cards)

    const suitCount = Object.values(checkSuitCount(userCard));
    const isFlush = suitCount.includes(5);

    const sortedRanks = userCard.map(card => card.rank).sort((a, b) => a - b);

    let isStraight = false;

    if (userCard.length === 5) {
        isStraight = true;

        const uniqueRanks = new Set(sortedRanks);
        if (uniqueRanks.size !== 5) {
            isStraight = false;
        } else {
            for (let i = 1; i < sortedRanks.length; i++) {
                if (sortedRanks[i]! - 1 !== sortedRanks[i - 1]!) {
                    isStraight = false;
                    break;
                }
            }

            if (sortedRanks.join() === "2,3,4,5,14") {
                isStraight = true;
            }
        }
    }

    const rankCounts = Object.values(checkRankCount(userCard));

    if (isStraight && isFlush && sortedRanks[0] === 10) return 10;
    if (isStraight && isFlush) return 9;
    if (rankCounts.includes(4)) return 8;
    if (rankCounts.includes(3) && rankCounts.includes(2)) return 7;
    if (isFlush) return 6;
    if (isStraight) return 5;
    if (rankCounts.includes(3)) return 4;
    if (rankCounts.filter(c => c === 2).length === 2) return 3;
    if (rankCounts.includes(2)) return 2;

    return 1;
}

五、单元测试

使用 Jest 做基础测试。

核心验证:

  • 所有牌型覆盖
  • 边界情况(A2345)
  • 非法输入

运行:

npm test

结果:

12 passed, 0 failed

六、这一阶段的意义

这一篇其实只做了一件事:

👉 把“游戏规则”从脑子里落到代码里

但这是整个项目最关键的一步。

因为:

  • 后面所有系统都依赖它
  • 它决定了数据结构设计
  • 它决定了状态流转方式

如果这一层不稳,后面全部要推翻重来。


七、下一步

写到这里,其实问题才刚刚开始。

目前这个实现有一个明显限制:

👉 所有逻辑都是“函数级别”,没有任何状态管理。

但在真实后端中:

  • 玩家是谁?
  • 当前回合在哪一步?
  • 状态如何流转?

这些问题,都还没被解决。

下一篇,我会把这套逻辑“搬进一个真正的服务结构里”,开始处理第一个关键问题:

👉 状态从哪里来?又该由谁维护?


结语

这个系列不会做成“教程式”。

而是更偏向:

一个真实后端项目从 0 到 1 的完整记录

如果你也在做:

  • 游戏后端
  • 实时系统
  • 或者想提升工程能力

这个系列应该会有帮助。


如果你也在做类似项目,或者有不同的设计思路,欢迎一起交流 👍