本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统
涵盖规则实现、架构设计、WebSocket 通信、模块拆分以及工程化演进
- 项目地址:balatro-realtime-backend
- 📌 对应代码版本:
feat(game): implement play/discard logic and move dealCards to game layer(2026-05-08) - ⚠️ 本文基于该 commit ,仓库代码可能已更新
前面三篇,我分别做了三件事:
- 把牌型判断从“想法”变成代码
- 把代码从“脚本”升级成“架构”
- 让服务器开始“记住状态”(deck / hand)
但到这里,我很快意识到一个问题:
❗ 玩家其实还“参与不了游戏”
系统可以发牌,但不能操作。
这意味着:
👉 这个项目还不能算是一个“游戏”,更像是一个工具集合。
所以这一篇,我要解决一个非常核心的问题:
🎯 让玩家真正参与进来,让状态开始“流动”
📚 系列文章:
(1)项目规划与牌型判断实现
(2)NestJS框架搭建与项目结构设计
(3)洗牌、发牌与服务端牌堆状态管理
(4)玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计(当前)
(5)得分计算与回合结算
(6)Blind关卡状态设计与回合推进实现
(7)Boss Blind与特殊规则实现(开发中)
一、本篇解决什么问题?
在前三篇中,我已经完成了:
- ✔ 规则层(牌型判断)
- ✔ 架构层(NestJS + 模块拆分)
- ✔ 状态层(发牌 + 牌堆维护)
但缺少一个关键能力:
❗ 玩家操作
这一篇的目标就是:
👉 让系统从“能发牌”,变成“能操作”
二、本篇核心能力
这一阶段,我主要做了这些事情:
- 实现出牌(play)与弃牌(discard)
- 抽象
selectCards统一操作入口 - 实现自动补牌(remove + draw)
- 将
dealCards从 poker 迁移到 game(职责回归) - 将
GameState归位到 game 层 - 引入参数校验体系
- 设计统一返回结构
- 拆分错误码体系
一个明显的转折点
写到这里,我开始有一个很强的感受:
❗ 系统已经不再是“接口调用”,而是在围绕“状态流转”运行
三、为什么必须实现出牌 / 弃牌 / 补牌?
在只有发牌的情况下:
- 玩家无法操作
- 游戏无法推进
- 状态不会变化
本质上:
👉 这还不是一个游戏
我把整个流程简单抽象了一下:
发牌 → 操作 → 状态变化 → 继续操作
如果缺少“操作”这一环:
👉 后面的所有设计都没有意义
四、dealCards 为什么必须迁移到 game?
一开始我把 dealCards 放在 poker 里,因为直觉上:
发牌 ≈ 牌操作
但在实现出牌/弃牌的时候,我发现一个问题:
❗ 发牌其实依赖“玩家状态”
比如:
- 当前 hand
- 剩余 deck
- playsLeft
- discardsLeft
这时候我才意识到:
👉 dealCards 本质是“流程行为”,而不是“规则计算”
最终我做的调整
game:负责流程 + 状态poker:负责规则 + 工具
并且明确了一条原则:
❗ 依赖只能单向:game → poker
❗ 禁止反向调用
五、selectCards:统一抽象操作流程
从用户视角:
- 出牌
- 弃牌
是两种不同操作。
但在实现过程中,我发现:
👉 服务端处理流程其实是一样的
选牌 → 移除 → 补牌 → 更新状态
唯一的区别是:
| 操作 | 消耗 |
|---|---|
| play | playsLeft |
| discard | discardsLeft |
于是我做了一个抽象:
selectCards(
selectedCards: string[],
action: "play" | "discard",
playerId: string
)
这样做的好处
- 减少重复逻辑
- 流程统一
- 后续扩展(技能 / 特效)更容易
六、为什么必须做参数校验?
这一块一开始我其实是想“先不做”的。
但在调试过程中,很快就遇到各种问题:
- 空数组
- 非法牌
- 重复选择
- 选择不存在的牌
- 超出数量限制
- 次数耗尽
这时候我才意识到:
❗ 如果没有校验,整个系统是非常脆弱的
最终我加上的校验逻辑
if (!selectedCards?.length) { ... }
if (selectedCards.length > MAX) { ... }
if (!CARD_PATTERN.test(card)) { ... }
if (duplicate) { ... }
if (!existInHand) { ... }
if (playsLeft <= 0) { ... }
一个很重要的认知
👉 校验不是“代码冗余”,而是“系统边界”
七、补牌机制:真正的状态流
一开始我把:
- 出牌
- 弃牌
- 补牌
当成三件事来看。
但在实现过程中,我发现:
❗ 它们其实是“一次状态更新”
完整流程是:
移除选中牌
↓
从 deck 抽牌
↓
更新 hand
↓
返回新状态
核心实现
private removeAndDrawCards(
selectedCards: string[],
handCards: string[],
playerState: GameState
): string[] {
const newHand: string[] = [];
const deck = playerState.deck;
for (let card of handCards) {
if (!selectedCards.includes(card)) {
newHand.push(card);
}
}
const need = playerState.handSize - newHand.length;
const draw = this.pokerService.serializeCards(
deck.splice(0, need)
);
newHand.push(...draw);
return newHand;
}
一个关键变化
👉 操作的结果,不是“提示”,而是“新状态”
八、返回结构设计
在设计返回数据时,我做了一个明确的约束:
❗ 前端只负责展示,不负责计算
所以返回结构被我拆成三类:
1️⃣ 状态数据
- hand
- playsLeft
- discardsLeft
- remainingDeckCount
2️⃣ 操作信息
- selectedCards
(主要用于前端动画 / UI反馈)
3️⃣ 流程状态
- gameOver
最终结构
type PlayCardsResult = {
code: number;
hand: string[];
playsLeft: number;
discardsLeft: number;
remainingDeckCount: number;
selectedCards: string[];
gameOver: boolean;
};
九、错误码分层设计
一开始我也写过这种代码:
return 351;
短期很快,但很快就开始失控:
- 不知道代表什么
- 不好维护
- 不可复用
后来我改成了分层设计:
RESULT_CODE
PLAYER_STATE_CODE
REQUEST_PARAM_CODE
GAME_FLOW_CODE
示例
EMPTY_SELECTED_CARDS: 351
CARD_NOT_IN_HAND: 353
NO_PLAYS_LEFT: 401
一个关键认知
👉 错误码本质上是“协议”,而不是数字
十、当前系统的局限
虽然现在已经可以:
- 发牌
- 出牌
- 弃牌
- 补牌
但还缺少一个关键部分:
游戏核心机制
- 得分计算
- 回合结算
- 胜负判定
当前状态可以总结为:
👉 能玩,但还不知道输赢
十一、本篇本质总结
功能层
- ✔ 操作能力(play / discard)
- ✔ 自动补牌
- ✔ 状态维护
架构层
- ✔ GameState 归位
- ✔ dealCards 迁移
- ✔ selectCards 抽象
- ✔ 错误码分层
系统层变化
❗ 从“发牌系统” → “游戏状态系统”
十二、下一步
到这里,我才真正进入“游戏核心逻辑”。
下一篇我会开始处理:
🎯 得分系统 + 回合结算
包括:
- 牌型接入
- 分数计算
- 是否达标
- 游戏结束判断
结语
这一篇最关键的,其实不是“怎么写出牌逻辑”。
而是:
👉 我开始在实现“游戏循环”
走到这里,我能明显感觉到这个项目发生了一次质变:
✔ 从工具函数 → 状态系统
✔ 从接口调用 → 游戏流程
✔ 从逻辑实现 → 玩法驱动
接下来,就是让这个系统“真正好玩起来”的阶段了 🚀