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

  • 这篇文章开始一个新的长期系列:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。
  • GitHub地址: https://github.com/RainbowZhou93/balatro-realtime-backend
    • 📌 本文对应代码版本:commit: init project with poker hand evaluator and unit test(2026年3月12日 10:57)
  • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该 commit 版本进行说明。

本项目不仅仅是算法练习,也不是简单的游戏复刻,而是希望通过完整实现一个卡牌游戏后端,逐步实践后端工程中常见的核心能力,包括:

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

选择 Balatro 作为实现对象,是因为它同时具备:

  • 明确的回合制状态流转
  • 复杂但可拆分的规则系统
  • 可扩展的卡牌效果机制
  • 适合逐步工程化演进

这使得它非常适合作为一个长期的后端工程实践项目。

本系列将按照阶段逐步推进,并在每一步记录设计思路与实现过程。

本文是第一篇,主要完成:

✔ 明确项目阶段目标

✔ 实现基础牌型判断逻辑

文章目录

一、技术栈规划

本项目后端主要使用 Node.js + TypeScript + NestJS构建,并逐步引入完整的工程化组件。

当前计划使用的技术栈:

  • Node.js v22
  • TypeScript 5.x
  • NestJS(后端框架)
  • WebSocket(实时通信)
  • Jest(单元测试)
  • Redis(缓存与会话状态)
  • MySQL(持久化存储)
  • Docker / Docker Compose(环境管理)

前期先实现核心规则逻辑,随后逐步加入接口层、状态管理、缓存层与持久化层。

二、项目阶段规划

本项目不会一次性实现全部功能,而是按照从核心逻辑 → 工程化 → 扩展系统 → 高级特性的顺序逐步推进。

第一阶段:单局游戏流程完整

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

  1. 目标包括
  • 可运行的 Game Engine
  • 支持回合状态
  • 支持目标分数
  • 支持发牌 / 出牌 / 弃牌 / 补牌
  • 支持得分计算
  • 支持回合结算
  • 支持 WebSocket 主流程调用,并预留 HTTP 查询接口
  1. 需要实现
  • NestJS框架搭建
  • GameState设计
  • 洗牌
  • 发牌(初始8张)
  • 出牌(最多5次)
  • 弃牌(最多3次)
  • 补牌
  • 牌型判断
  • 得分计算
  • 判断是否达标
  • 回合结算
  1. 当前进度
    ✔牌型判断

第二阶段:关卡与盲注系统

目标:让项目从"一局逻辑"变成"有进度的游戏"

包括:

  • 小盲
  • 大盲
  • Boss
  • 目标分数递增
  • 允许跳过盲注
  • 回合推进
  • 局结束判断

第三阶段:持久化与缓存分层

包括:

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

第四阶段:工程化与部署

包括:

  • Docker Compose
  • 配置化
  • 日志系统
  • 单元测试
  • 历史查询接口
  • README 初版
  • 架构图初版

第五阶段:卡牌效果与Modifier系统

包括:

  • Joker小丑牌
  • 增强卡
  • 封蜡
  • 得分Modifier
  • MultiplierModifier
  • CardEffect
  • JokerEffect
  • Trigger
  • EventBus

第六阶段:奖励池与商店系统

包括:

  • 商店
  • 补充包
  • 标签
  • 刷新
  • 购买
  • 奖励池
  • 权重随机

第七阶段:特殊卡牌系统

包括:

  • 塔罗牌
  • 星球牌
  • 幻灵牌
  • 特殊事件

第八阶段:扩展玩法与AI系统

包括:

  • 房间系统
  • 排行榜
  • 自动对局 AI
  • Go 子服务预研
  • Agent 扩展构想

三、牌型判断实现

牌型判断是整个游戏规则系统的基础,也是后续得分计算、效果系统、Modifier 系统的核心依赖,因此先从纯逻辑层实现。

1. 目标

输入为 1~5 张牌,根据当前出牌组合判断牌型。

  • 支持牌型:
    • royalFlush > 皇家同花顺 > 10
    • straightFlush > 同花顺 > 9
    • fourOfAKind > 金刚 > 8
    • fullHouse > 葫芦 > 7
    • flush > 同花 > 6
    • straight > 顺子 > 5
    • threeOfAKind > 三条 > 4
    • twoPair > 两对 > 3
    • onePair > 一对 > 2
    • highCard > 杂牌 > 1

2. 规则

  • 点数:2~10 J Q K A

    • A = 14

    • 如其余牌是2、3、4、5,则A = 1,牌型为顺子

  • 花色:

    • H(Hearts): 红桃♥️
    • S(Spades):黑桃♠️
    • D(Diamonds): 方片♦️
    • C(Clubs): 梅花♣️
  • 输入

    • 10H JH QH KH AH
  • 输出

    • 10

3. 代码实现

ts-node xx.ts运行

typescript 复制代码
/**
 * 输入最多5张牌,根据牌的点数和花色,判断是否是皇家同花顺,同花顺、四条、葫芦、同花、顺子、三条、两对、一对、高牌、杂牌等牌型。
 * 牌的点数从2到10,J、Q、K、A分别对应11、12、13、14。
 * 花色有四种:红桃(H)、黑桃(S)、方块(D)、梅花(C)。
 * 输出牌型的名称,例如:皇家同花顺royalFlush,同花顺straightFlush、四条fourOfAKind、葫芦fullHouse、同花flush、顺子straight、三条threeOfAKind、两对twoPair、一对onePair、高牌highCard、杂牌junk。
 * 
 * 输入格式:
 * 输入最多5张牌,牌的格式为点数加花色,例如:10H、JD、QS、KC、AD。
 * 输入结束后,输出对应的牌型名称。
 * 
 * 示例输入:
 * 10H JD QS KC AD
 * 2H 3D 4S 5C 6H
 * 2H 2D 2S 5C 6H  
 * 
 * 示例输出:
 * 10
 */

type Suit = 'H' | 'S' | 'D' | 'C';
type HandType = 'royalFlush' | 'straightFlush' | 'fourOfAKind' | 'fullHouse' | 'flush' | 'straight' | 'threeOfAKind' | 'twoPair' | 'onePair' | 'highCard';

type Card = {
    rank: number,
    suit: Suit
}


const cardType: Record<HandType, number> = {
    royalFlush: 10,
    straightFlush: 9,
    fourOfAKind: 8,
    fullHouse: 7,
    flush: 6,
    straight: 5,
    threeOfAKind: 4,
    twoPair: 3,
    onePair: 2,
    highCard: 1,
}

const rankMap: Record<string, number> = {
    A: 14,
    K: 13,
    Q: 12,
    J: 11
}

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++) {
                // 这里使用非空断言,是因为当前循环边界已经保证 sortedRanks[i] 与 sortedRanks[i - 1] 一定存在。
                if (sortedRanks[i]! - 1 != sortedRanks[i - 1]!) {
                    isStraight = false;
                    break;
                }
            }
            // sortedRanks.join() 判断sortedRanks中的元素是否是2、3、4、5、14(A)。如果是的话,说明这是一个特殊的顺子,A在这里被当作1来使用。
            if (sortedRanks.join() === "2,3,4,5,14") isStraight = true;
        }


    }

    const rankCount = checkRankCount(userCard);
    const rankCounts = Object.values(rankCount);
    if (isStraight && isFlush && sortedRanks[0] === 10) return cardType.royalFlush;
    if (isStraight && isFlush) return cardType.straightFlush;
    if (rankCounts.includes(4)) return cardType.fourOfAKind;
    if (rankCounts.includes(3) && rankCounts.includes(2)) return cardType.fullHouse;
    if (isFlush) return cardType.flush;
    if (isStraight) return cardType.straight;
    if (rankCounts.includes(3)) return cardType.threeOfAKind;
    /**
     * count => count === 2 是一个回调函数,判断每个元素是否等于2。filter方法会返回一个新数组,包含所有满足条件的元素。
     * 例如,如果rankCounts是[1, 2, 2, 1],那么rankCounts.filter(count => count === 2)会返回[2, 2],因为有两个元素等于2。
     * 然后我们检查这个新数组的长度是否等于2,如果是的话,说明我们有两对牌。
     */
    if (rankCounts.filter(count => count === 2).length === 2) return cardType.twoPair;
    if (rankCounts.includes(2)) return cardType.onePair;

    return cardType.highCard;
}

/**
 * 
 * @param cards ['10H', 'JD', 'KS', '9C']
 * @returns: [{rank:10,suit:'H'},{rank:11,suit:'D'},{rank:12,suit:'S'},{rank:13,suit:'C'},{rank:14,suit:'D'}]
 */
function parseCard(cards: string[]): Card[] {
    const validSuits: Set<Suit> = new Set(['H', 'S', 'D', 'C']);

    return cards.map((card) => {
        const suit = card.slice(-1);
        const rankStr = card.slice(0, -1) || "0";
        const rank = rankMap[rankStr] ?? Number(rankStr);
        //这里使用 as Suit 进行类型断言,用于通过 Set<Suit> 的类型检查。这类断言只影响 TypeScript 编译期,不会在运行时做额外校验。
        if (!validSuits.has(suit as Suit) || Number.isNaN(rank)) {
            throw new Error(`Invalid card format rank: ${card}`);
        }
        return { rank, suit: suit as Suit }
    });
}

/**
 * 
 * @param cards: [{rank:10,suit:'H'},{rank:11,suit:'D'},{rank:12,suit:'S'},{rank:13,suit:'C'},{rank:14,suit:'D'}]
 * @returns: { '3': 1, '5': 1, '8': 1, '10': 1, '11': 1 }
 */
function checkRankCount(cards: Card[]): Record<number, number> {
    const rankCount: Record<number, number> = {};
    for (let card of cards) {
        rankCount[card.rank] = (rankCount[card.rank] || 0) + 1;
    }
    return rankCount;
}

/**
 * 
 * @param cards [{rank:10,suit:'H'},{rank:11,suit:'D'},{rank:12,suit:'S'},{rank:13,suit:'C'},{rank:14,suit:'D'}]
 * @returns: { H: 1, S: 1, D: 2, C: 1 }
 */
function checkSuitCount(cards: Card[]): Record<Suit, number> {
    const suitCount: Record<Suit, number> = { H: 0, S: 0, D: 0, C: 0 };
    for (let card of cards) {
        suitCount[card.suit]++;
    }
    return suitCount;
}

// console.log(getCardType(['10H', 'JD', 'QS', 'KC', 'AD']));
export { getCardType }

4. 单元测试

使用Jest做单元测试

1) Jest安装

bash 复制代码
//安装 安装要求{ node: '^20.19.0 || ^22.13.0 || >=24' }
npm install --save-dev jest ts-jest @types/jest

//初始化
npx ts-jest config:init

//package.json 添加 (有tsconfig.json,也要有package.json 不冲突)
"scripts": {
    "test": "jest"
  }
"type": "module"

//如果没有package.json,就在项目主目录下init
npm init -y

//如果没有tsconfig.json
npx tsc --init

2) 需注意

  • 生成的jest.config.js中,还在用module.exports = { ... }(CommonJS写法),就会有冲突,因为package.json中是"type":"module",所以需要把文件jest.config.js修改后缀为jest.config.cjs

3) 单元测试代码

运行:npm test

typescript 复制代码
import { getCardType } from "../src/games/hand/handEvaluator";

describe("Poker Hand Type Test", () => {

    test("Royal Flush", () => {
        const result = getCardType(['QH', '10H', 'AH', 'JH', 'KH']);
        expect(result).toBe(10);
    });

    test("Straight Flush", () => {
        const result = getCardType(['9S', '6S', '10S', '7S', '8S']);
        expect(result).toBe(9);
    });

    test("Four of a kind", () => {
        const result = getCardType(['9C', '9H', '9D', '9S']);
        expect(result).toBe(8);
    });

    test("Full House", () => {
        const result = getCardType(['6H', '3D', '3H', '6C', '3S']);
        expect(result).toBe(7);
    });

    test("Flush", () => {
        const result = getCardType(['KH', '2H', '9H', '5H', '7H']);
        expect(result).toBe(6);
    });

    test("Straight", () => {
        const result = getCardType(['7C', '4H', '8H', '6D', '5S']);
        expect(result).toBe(5);
    });

    test("Straight A2345", () => {
        const result = getCardType(['3D', 'AH', '5H', '2S', '4C']);
        expect(result).toBe(5);
    });

    test("Three of a kind", () => {
        const result = getCardType(['KH', '7D', '9H', '7H', '7S']);
        expect(result).toBe(4);
    });

    test("Two Pair", () => {
        const result = getCardType(['9C', '5H', '9D', '5S']);
        expect(result).toBe(3);
    });

    test("One Pair", () => {
        const result = getCardType(['2D', 'KH', '8H', '5C', '8S']);
        expect(result).toBe(2);
    });

    test("High Card", () => {
        const result = getCardType(['JC', '2H', 'KH', '9D', '5S']);
        expect(result).toBe(1);
    });

    test("Invalid card", () => {
        expect(() => {
            getCardType(['9D', 'KH', 'XX', '5S', 'JC']);
        }).toThrow();
    });

});
  • 输出
bash 复制代码
rainbow@bogon tests % npm test

> balatro@1.0.0 test
> jest

 PASS  tests/poker.test.ts
  Poker Hand Type Test
    ✓ Royal Flush (4 ms)
    ✓ Straight Flush (1 ms)
    ✓ Four of a kind
    ✓ Full House (1 ms)
    ✓ Flush (2 ms)
    ✓ Straight (1 ms)
    ✓ Straight A2345 (1 ms)
    ✓ Three of a kind (2 ms)
    ✓ Two Pair (2 ms)
    ✓ One Pair (1 ms)
    ✓ High Card (1 ms)
    ✓ Invalid card (16 ms)

Test Suites: 1 passed, 1 total
Tests:       12 passed, 12 total
Snapshots:   0 total
Time:        1.743 s, estimated 2 s
Ran all test suites.

四、下一步计划

下一篇将开始搭建 NestJS 项目结构,并将当前的纯逻辑代码迁移到后端框架中,同时设计游戏状态对象 GameState,为后续回合流程和 WebSocket 接口做准备。

本系列将持续记录从规则实现到工程化落地的全过程。

相关推荐
旺仔老馒头.1 小时前
【C++】类和对象(二)
开发语言·c++·后端·类和对象
广东王多鱼1 小时前
一个人 + Claude = 全栈开发团队:从零构建 AI 自动化开发系统的技术实现
后端·vibecoding
用户2160719532951 小时前
AQS、ReentrantLock详解
后端
Rust研习社1 小时前
Rust Clippy 实用指南:写出更优雅、安全的 Rust 代码
后端·rust·编程语言
小撒的私房菜1 小时前
Agent = Model + Harness:这个公式,让我重新理解了 AI 工程
人工智能·后端
掘金者阿豪1 小时前
Go 语言操作金仓数据库(下篇):SQL 执行、类型映射与超时控制
后端
IVEN_1 小时前
全栈开发必看:从内存变量到关系型数据库的完整旅程
后端
MacroZheng1 小时前
横空出世!IDEA最强MyBatis插件来了,功能很全!
java·后端·mybatis
codebetter1 小时前
X86 Windows Docker Desktop 运行 arm64 容器
后端