从0到1实现 Balatro 游戏后端(2):NestJS框架搭建与项目结构设计

56 阅读9分钟
  • 本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统, 包括规则实现、架构设计、WebSocket 通信、模块拆分以及后续工程化改造。
  • GitHub地址: balatro-realtime-backend
    • 📌 本文对应代码版本:feat: initialize NestJS modules, add WebSocket gateway, migrate hand evaluator(2026年3月16日 16:36)
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该 commit 版本进行说明。

第一篇里,我只是把牌型判断逻辑跑通了。

但写完之后,我很快意识到一个问题:

如果后面继续往里面加:

  • WebSocket 通信
  • 游戏状态管理
  • 房间系统
  • 数据库存储

这个结构迟早会开始失控。

因为第一版的代码,本质上还是:

“单文件逻辑”

而不是一个真正的后端系统。

所以这一篇开始,我决定先不继续堆功能,而是先把整个项目迁移到 NestJS,把后面的架构基础先搭起来。


📚 系列文章:

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


✅本篇实现了什么

这一篇主要完成:

  • 将原本的单文件逻辑迁移到 NestJS
  • 拆分 Game 与 Poker 模块
  • 接入 WebSocket
  • 建立 Gateway → Service → 业务模块调用链

最终得到一个:

👉 具备模块化结构,并支持 WebSocket 调用的后端基础框架。


一、为什么要迁移到 NestJS

1. 存在的问题

第一篇中,牌型判断逻辑还是以 handEvaluator.ts 的形式直接使用原生 TypeScript 编写。

所有逻辑都集中在单个文件里。

这种方式在项目初期其实非常直接。

因为:

  • 功能少
  • 文件少
  • 调试也方便

但问题是:

随着功能越来越多,单文件结构迟早会开始变乱。

2. 预期规划

从一开始决定做这个 Balatro 后端时,我其实就已经预期:

后面一定会继续加入:

  • WebSocket 通信
  • 网关分发
  • 数据库存储
  • 模块拆分
  • 更低耦合的业务结构

如果继续手动组织文件,后面:

  • 职责会越来越混乱
  • 模块依赖越来越复杂
  • 维护成本也会越来越高

所以这一阶段,我决定先把项目迁移到后端框架中,把后面的结构基础提前搭好。


3. 为什么最后选择了 NestJS

当时我对比过:

  • Express
  • Pomelo
  • NestJS

最后还是选择了 NestJS

主要原因:

  • 模块化结构比较完整
  • 自带依赖注入(DI)
  • 官方支持 WebSocket
  • 更适合长期维护项目

相比继续使用原生方式手动组织文件,NestJS 更像是在一开始就给项目建立“结构约束”。

这样后面继续扩展时,不容易失控。


二、为什么要把 poker 单独做成 module,而不是写在 game 里

1. 为什么要拆分 poker 模块

在项目结构设计时,我并没有把所有逻辑都放进 game 模块。

而是单独拆出了一个 poker 模块。

原因其实很简单:

不同逻辑,本身就属于不同领域。

比如:

  • 扑克牌规则
  • 出牌流程
  • 房间管理
  • 玩家连接

这些其实是完全不同的东西。

如果全部写在一个模块里,后面代码量一大,很容易变成“一锅粥”。

所以从一开始,我就尽量按“业务领域”拆模块,让每个模块只负责自己的事情。

另一方面,NestJS 本身就是一个非常强调模块化的框架。

既然已经选择了它,我也更倾向于按照它推荐的结构去组织项目。

这样后面扩展时会更自然,比如以后新增:

room/
player/
blind/
modifier/

都可以继续往下拆,而不是把所有逻辑继续塞进 game


2. 总结

这种“按业务领域拆模块”的方式,其实在很多后端项目里都很常见。

无论是游戏后端,还是电商、推荐系统,本质上都是:

规则、流程、通信,各自拆开。

这样后面维护会轻松很多。


三、为什么使用 WebSocket 而不是 HTTP

1. 为什么使用 WebSocket

在通信方式上,我没有选择 HTTP,而是直接用了 WebSocket

因为:

HTTP 更适合:

  • 查询
  • 一次性请求
  • 请求-响应模型

而游戏后端更需要:

  • 长连接
  • 实时交互
  • 状态同步

尤其是后面:

  • 出牌
  • 房间
  • 玩家同步

这些功能加进来之后,如果继续使用 HTTP,会需要额外处理大量状态问题。

所以我一开始就决定:

直接按实时系统去设计。


因此项目最终使用:

  • NestJS Gateway
  • WebSocket

来作为核心通信方案。


2. 总结

相比 HTTP 的请求-响应模式:

WebSocket 更适合这种需要持续状态交互的系统。

尤其是在游戏后端里,基本已经算是标准选择。


四、为什么 handEvaluator 不再是单文件,而是放进 PokerService

第一版里,牌型判断全部写在:

handEvaluator.ts

这种方式在功能少的时候很直接。

但开始模块化之后,再继续保持单文件结构,其实已经不太适合了。


在当前项目中:

  • GameService: 负责流程控制

  • PokerService: 负责规则计算

  • Gateway: 负责通信入口

我开始有意识地把:“规则”“流程” 拆开。

因为牌型判断本质上属于:扑克牌规则,而不是游戏流程。

所以最后我把: handEvaluator.ts 迁移进了:PokerService

这样后面:

  • 测试
  • 扩展
  • AI模拟
  • Modifier系统

都会更容易处理。


五、WebSocket 请求是如何从 Gateway 到 Service 再到 PokerService 的

1. 请求逻辑

当前调用链是:

Client
  ↓
Gateway
  ↓
GameService
  ↓
PokerService

客户端消息先进入:Gateway

由 Gateway 负责接收 WebSocket 请求。

之后:

  • GameService: 负责流程控制

  • PokerService: 负责规则计算


计算完成后:

PokerService
  ↓
GameService
  ↓
Gateway
  ↓
Client

再逐层返回。


这种拆分方式,其实也是我后面一直在坚持的一个原则:

通信、流程、规则,尽量分层。

这样后面功能越来越多的时候,结构不会乱掉。


六、当前项目结构设计

1. 当前目录结构

src/
├─ game/
│  ├─ game.gateway.ts
│  ├─ game.module.ts
│  └─ game.service.ts
├─ poker/
│  ├─ poker.constants.ts
│  ├─ poker.module.ts
│  ├─ poker.service.ts
│  └─ poker.types.ts
├─ app.module.ts
└─ main.ts

2. 结构说明

当前结构里:

  • game: 负责流程与通信

  • poker: 负责扑克牌规则


在 poker 模块内部:

  • constants
  • types
  • service

也分别拆开。

而不是全部塞进一个文件。

这样逻辑会清晰很多。

3. 代码实现

下面代码展示了从客户端请求到牌型计算的完整调用链: ClientGatewayGameServicePokerService 建议重点关注:

  • Gateway 如何接收 WebSocket 消息
  • GameService 如何承接流程控制
  • PokerService 如何完成牌型计算

1. game.gateway.ts

import {
    ConnectedSocket,
    MessageBody,
    OnGatewayConnection,
    OnGatewayDisconnect,
    SubscribeMessage,
    WebSocketGateway,
} from "@nestjs/websockets";
import { Logger } from "@nestjs/common";
import { GameService } from "./game.service";

type GatewayClient = WebSocket & {
    _socket?: {
        remoteAddress?: string;
        remotePort?: number;
    };
    __clientId?: string;
};

@WebSocketGateway()
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
    private readonly logger = new Logger(GameGateway.name);
    private clients = new Map<string, WebSocket>();
    private clientIdCounter = 1;

    constructor(private readonly gameService: GameService) {}

    handleConnection(@ConnectedSocket() client: GatewayClient) {
        const sock = client._socket;
        const ip = sock?.remoteAddress;
        const port = sock?.remotePort;
        const id = ip && port ? `${ip}:${port}` : `client_${this.clientIdCounter++}`;

        client.__clientId = id;
        this.clients.set(id, client);
        this.logger.log(`Client connected: ${ip}:${port}, assigned ID: ${id}`);
    }

    handleDisconnect(@ConnectedSocket() client: GatewayClient) {
        const id = client.__clientId;

        if (id) {
            this.clients.delete(id);
            this.logger.log(`Client disconnected: ${id}`);
        } else {
            this.logger.log(`Client disconnected: unknown client`);
        }
    }

    @SubscribeMessage("message")
    handleMessage(@MessageBody() data: string, @ConnectedSocket() client: GatewayClient): string {
        this.logger.log(`Received message from client ${client.__clientId}: ${data}`);
        return data;
    }

    @SubscribeMessage("handEvaluator")
    handleHandEvaluator(@MessageBody() data: string[], @ConnectedSocket() client: GatewayClient): number {
        const handType = this.gameService.playCard(data);
        this.logger.log(`Received hand evaluation request from client ${client.__clientId}: ${JSON.stringify(data)}`);
        return handType;
    }
}

2. game.service.ts

import { Injectable, Logger } from "@nestjs/common";
import { PokerService } from "../poker/poker.service";

@Injectable()
export class GameService {
    private readonly logger = new Logger(GameService.name);
    constructor(private readonly pokerService: PokerService) {}
    playCard(cards: string[]): number {
        const handType = this.pokerService.getCardType(cards);
        return handType;
    }
}

3. poker.service.ts

PokerService 作为规则层模块,不依赖任何游戏流程状态,仅负责纯计算逻辑。 这种“纯函数式服务”的设计,可以让规则层在测试、复用以及未来扩展时更加灵活。

import { Injectable, Logger } from "@nestjs/common";
import { Card, Suit } from "./poker.types";
import { RANK_MAP, CARD_TYPE } from "./poker.constants";

@Injectable()
export class PokerService {
    private readonly logger = new Logger(PokerService.name);
    constructor() {}
    public getCardType(cards: string[]): number {
        this.logger.log(`Evaluating hand: ${JSON.stringify(cards)}`);
        const userCard = this.parseCard(cards);
        const suitCount = Object.values(this.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;
                    }
                }
                // sortedRanks.join() 判断sortedRanks中的元素是否是2、3、4、5、14(A)。如果是的话,说明这是一个特殊的顺子,A在这里被当作1来使用。
                if (sortedRanks.join() === "2,3,4,5,14") isStraight = true;
            }
        }

        const rankCount = this.checkRankCount(userCard);
        const rankCounts = Object.values(rankCount);
        if (isStraight && isFlush && sortedRanks[0] === 10) return CARD_TYPE.royalFlush;
        if (isStraight && isFlush) return CARD_TYPE.straightFlush;
        if (rankCounts.includes(4)) return CARD_TYPE.fourOfAKind;
        if (rankCounts.includes(3) && rankCounts.includes(2)) return CARD_TYPE.fullHouse;
        if (isFlush) return CARD_TYPE.flush;
        if (isStraight) return CARD_TYPE.straight;
        if (rankCounts.includes(3)) return CARD_TYPE.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 CARD_TYPE.twoPair;
        if (rankCounts.includes(2)) return CARD_TYPE.onePair;

        return CARD_TYPE.highCard;
        return 1;
    }

    /**
     * @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"}]
     */
    private 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 = RANK_MAP[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 }
     */
    private checkRankCount(cards: Card[]): Record<number, number> {
        const rankCount: Record<number, number> = {};
        for (const 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 }
     */
    private checkSuitCount(cards: Card[]): Record<Suit, number> {
        const suitCount: Record<Suit, number> = { H: 0, S: 0, D: 0, C: 0 };
        for (const card of cards) {
            suitCount[card.suit]++;
        }
        return suitCount;
    }
}

4. poker.constants.ts

import { HandType } from "./poker.types";

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

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

export { CARD_TYPE, RANK_MAP };

5. poker.types.ts

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

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

export type { Suit, HandType, Card };


七、总结

1. 当前阶段完成内容

  • ✔ 牌型判断实现
  • ✔ NestJS 初始化
  • ✔ WebSocket 接入
  • ✔ handEvaluator 迁移
  • ✔ 可以通过 WebSocket 调用牌型判断

2. 本阶段架构总结

这一阶段,其实最重要的不是“功能”。

而是:

项目开始从“单文件脚本”变成“模块化后端结构”。


目前已经完成:

  • 使用 NestJS 模块化拆分
  • 业务规则与流程控制分离
  • 使用 WebSocket 实现实时通信
  • 建立 Gateway → Service → 业务模块调用链

3. 为什么这一步重要

做到这里之后,项目后面才能继续往下扩展:

  • 回合系统
  • 卡牌效果
  • 房间系统
  • AI模块

否则后面代码量一大,结构一定会开始混乱。


4. 下一步计划

下一阶段会开始实现:

  • 洗牌
  • 发牌
  • 手牌管理

也就是:

真正开始进入“游戏状态管理”阶段。


本系列会持续记录:

从规则实现到工程化落地的整个过程。