这篇文章开始一个新的长期系列:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目
- balatro-realtime-backend -> GitHub地址、GitCode地址
📌 本文对应代码分支:origin/feature/scoring-settlement
⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。
✅ 本篇实现了什么
在这一篇中,我完成了让游戏真正跑起来的核心功能:
- 完整的单局游戏闭环,从开始到结算
dealCards升级为startGame,成为单局生命周期入口- 玩家出牌 / 弃牌统一接口
selectCards,支持分数计算 - 牌型分数计算与倍率逻辑,并增加
validCards标识参与计分的牌 - 服务端维护核心状态:
totalScore、currentActionScore、targetScore、gameStatus - 单元测试覆盖出牌、弃牌、得分计算以及游戏结束判断
简单来说,这一篇完成后,就以从开始到结束完整体验一局游戏了。
文章目录
- 一、为什么第五篇开始,项目开始像"游戏"了
- [二、把 dealCards 改为 startGame](#二、把 dealCards 改为 startGame)
-
- [1. dealCards 是什么](#1. dealCards 是什么)
- [2. 为什么职责开始变化](#2. 为什么职责开始变化)
- [3. 为什么必须改名](#3. 为什么必须改名)
- 三、为什么服务端必须维护得分状态
-
- [1. 前面的接口更多是即时行为](#1. 前面的接口更多是即时行为)
- [2. 得分成为持续状态](#2. 得分成为持续状态)
- [3. 为什么游戏状态必须由服务端维护](#3. 为什么游戏状态必须由服务端维护)
- [4. 为什么不把分数交给前端维护](#4. 为什么不把分数交给前端维护)
- 四、如何将牌型规则接入得分系统
-
- [1. 牌型判断并不等于得分计算](#1. 牌型判断并不等于得分计算)
- [2. 使用配置映射管理牌型分数](#2. 使用配置映射管理牌型分数)
- [3. validCards](#3. validCards)
-
- [3.1 为什么增加 validCards](#3.1 为什么增加 validCards)
- [3.2 不增加 validCards 会怎么样](#3.2 不增加 validCards 会怎么样)
- [4. calculateHandScore实现](#4. calculateHandScore实现)
- 五、如何实现单局操作与回合结算
-
- [1. 玩家出牌 / 弃牌的流程](#1. 玩家出牌 / 弃牌的流程)
- [2. 自动补牌与手牌排序](#2. 自动补牌与手牌排序)
- [3. 判断游戏结束与生成结算信息](#3. 判断游戏结束与生成结算信息)
- [4. 前端返回的数据结构设计](#4. 前端返回的数据结构设计)
- [5. 单局操作闭环概览](#5. 单局操作闭环概览)
- 六、总结
-
- [1. 当前阶段完成内容](#1. 当前阶段完成内容)
- [2. 本阶段总结](#2. 本阶段总结)
- [3. 为什么这一步重要](#3. 为什么这一步重要)
- [4. 第一阶段正式完成✅](#4. 第一阶段正式完成✅)
- [5. 下一步计划](#5. 下一步计划)
一、为什么第五篇开始,项目开始像"游戏"了
在前几篇中,我已经陆续实现了发牌、出牌、弃牌、补牌这些基础行为。
但从严格意义来讲,这些能力还只是一个个相对独立的功能点。玩家可以拿到手牌,也可以从手牌中选择牌进行出牌或弃牌,服务端也能根据操作移除用户手牌以及补牌操作。
但是截止到这里为止,系统还不能回答更关键的几个问题:
- 这一局的目标是什么?
- 玩家选择出的牌得了多少分?
- 当前累计分数是多少?
- 游戏什么时候结束?
- 结束之后。玩家是赢了还是输了?
也就是说,前面的实现更多的是在解决"玩家能做什么操作",而这一篇开始则要解决的是:这些操作如何被串成一局完整的游戏流程。
所以在这一篇中,我开始补上得分计算与单局结算相关的逻辑。
从 startGame 初始化一局游戏开始,到玩家自行出牌/弃牌操作,再到服务端根据出牌牌型计算得分、累计总分、判断是否达到目标分数或耗尽出牌次数,最终返回游戏是否结束以及胜负结果。
到这一步,项目就不再只是几个离散接口的组合,而是开始形成了一个最小可运行的单局闭环:
startGame
-> selectCards(play / discard)
-> calculate score
-> update game state
-> check game over
-> settlement
二、把 dealCards 改为 startGame
1. dealCards 是什么
其实在前几篇中,就已经提到过,后面会把 dealCards 改成 startGame。
因为在项目最开始时,dealCards 的职责非常单纯,它只是一个独立的洗牌与发牌接口,用来验证:
- 牌堆是否能够正确生成
- Fisher-Yates 洗牌是否正常
- 玩家是否能够正确拿到初始牌
哪个阶段,它更像是一个"功能测试入口"。
2. 为什么职责开始变化
随着项目逐渐推进,我开始发现:前面的接口已经不再是各自独立的行为了。
玩家的出牌、弃牌、补牌、得分计算、剩余次数、游戏结束判断,开始被逐渐串联起来。
系统也开始从几个离散接口 逐步演变成一局完整的游戏流程。
3. 为什么必须改名
而这个时候,原本dealCards 说承担的职责,便远远不够了,它开始负责
- 初始化牌堆
- 初始化玩家手牌及手牌数量
- 初始化剩余出牌/弃牌次数
- 初始化总分和本次操作分
- 初始化游戏状态
也就是说,它实际已经成为了:一局游戏生命周期的开始入口。
所以,继续使用 dealCards 这个名字,已经无法准确的表达它当前的职责了。因此在这一篇中,正式将其改为 startGame
这不仅仅是一次接口重命名,更重要的是:项目开始真正进入'游戏状态驱动'的状态。
三、为什么服务端必须维护得分状态
1. 前面的接口更多是即时行为
在前几篇中,实现的发牌、出牌、弃牌、补牌等功能,本质上都还是一次性的即时行为。
客户端发起请求,服务端返回结果,本次操作也就结束了。
例如:
- 发牌后,返回当前用户手牌
- 弃牌/出牌后,返回补牌后的结构,更新当前手牌
这些行为虽然已经能够完成交互,但它们之间其实还没有真正的形成状态关联。
也就是说:前面的接口,更像是一个个独立的功能调用。
2. 得分成为持续状态
但这一篇的'得分',已经不再是一个简单的接口返回值。
因为玩家当前的总分,这一次操作而得到的分,是否达成目标,游戏是否结束,最终的胜负结果等信息,都会持续影响后面的游戏流程。
例如:
- 当前总分是否达到目标分数
- 剩余出牌次数是否已经耗尽
- 当前游戏是否已经结束
- 玩家最终是
WIN还是LOSE
也就是说:得分已经开始成为"游戏状态"的一部分。
所以从这一篇开始,我将:
totalScorecurrentActionScoretargetScoregameStatus
全部纳入了服务端的 GameState 中统一维护。
3. 为什么游戏状态必须由服务端维护
这样设计最核心的原因是:服务端需要始终拥有完整且唯一的游戏状态。
客户端只负责:
- 发起操作
- 展示结果
而真正的游戏数据则全部由服务端负责维护与计算:
- 分数
- 剩余次数
- 游戏状态
- 胜负结果
这样保证:
- 状态唯一
- 后续扩展房间、
Blind、Buff、Modifier、Redis缓存时一致性 - 游戏逻辑可靠,不受客户端修改干扰
所以这一篇实际上不仅仅只是加了一个得分功能。
更重要的是:项目开始真正进入"状态驱动(State Driven)"的设计阶段。
4. 为什么不把分数交给前端维护
其实最开始我也想过一个更简单的方案:服务端只负责返回本次操作得分,由前端自行累计 totalScore。
这样服务端状态会简单很多。
但后来发现这种方式有几个问题:
- 前端数据容易被篡改
- 断线重连后状态恢复困难
- 多端同步时可能出现分数不一致
- 后续接入
Redis持久化会变复杂
因此最终还是决定由服务端统一维护总分状态。客户端只负责展示。
四、如何将牌型规则接入得分系统
1. 牌型判断并不等于得分计算
在之前的章节中,我们已经在 poker 模块中实现了牌型判断方法 getCardType(),它的职责是判断玩家手中的牌属于哪种牌型。
然而,牌型判断只是计算牌型得分的基础,并不是最终结果。要得到最终分数,还需要知道:
- 每种牌型的基础分是多少
- 每种牌型对应的分数倍率是多少
- 哪些牌才真正参与得分
也就是说,得分计算是在牌型判断的基础上进行的二次处理,两者的职责需要明确拆分。
2. 使用配置映射管理牌型分数
其实不管是基础分是多少,还是对应牌型的倍率是多少,都是简单的数字,甚至可以写到代码中,直接判断:
ts
if(straightFlush) {
socre = (100 + 牌的本身分数) * 8
}
不需要列出额外的参数,可以少些很多行代码。这种写法短期可行,但存在几个问题:
- 如果多处都直接写 (100 + ...) * 8,未来维护代码时容易混乱
- 不方便全局查看某个牌型的基础分或倍率
- 稍有疏忽,可能导致不同地方使用的规则不一致
因此,我们把牌型的基础分和对应倍率,写入对应配置中:
ts
export const CARD_SCORE_MAP: Record<HandType, number> = { ... };
export const CARD_MULTIPLIER_MAP: Record<HandType, number> = { ... };
这样做的好处是:
- 全局可查、统一管理;
- 维护和修改更加方便;
- 后续扩展特殊牌型或倍率时,只需修改配置,而不必到处改逻辑。
3. validCards
3.1 为什么增加 validCards
最初的 getCardType() 只返回了一个 number 来表示牌型类型。
但在实际计算得分时,我们发现:
- 并非玩家手中的所有牌都参与计算
- 只有组成牌型的牌才参与基础分和倍率计算
如果每次都在牌型判断后二次去找哪些牌参与得分,会造成重复计算和逻辑混乱。
因此,我们对 getCardType() 的返回值进行了优化,新增了 validCards: Card[] 字段,用于明确标识哪些牌是有效的得分牌。
这样:
- 牌型判断和得分计算职责明确分离
calculateHandScore()可以直接使用 validCards 进行分数计算- 逻辑清晰、可扩展性更好
3.2 不增加 validCards 会怎么样
如果不返回 validCards,calculateHandScore 则需要再次分析哪些牌参与计分。
这样会导致:
- 重复计算
- 牌型模块和得分模块耦合
- 后续特殊牌规则扩展困难
因此最终选择在牌型判断阶段直接返回 validCards,让
- 牌型模块负责:识别哪些牌有效。
- 得分模块负责:如何计算分数。
4. calculateHandScore实现
ts
public calculateHandScore(cards: string[]): {
baseScore: number;
multiplier: number;
handType: number;
validCards: string[];
} {
const handEvaluate: HandEvaluateResult = this.getCardType(cards);
const validCards = handEvaluate.validCards;
// console.log(`---validCards: ${JSON.stringify(validCards)}------`);
let baseScore: number = 0;
for (let i = 0; i < validCards.length; i++) {
const card = validCards[i];
baseScore += CARD_SCORE[card.rank] ?? card.rank;
}
// console.log(`befor baseScore: ${baseScore}`);
const handType = TYPE_CARD[handEvaluate.cardType];
if (!handType) {
throw new Error(`Unknown hand type: ${handEvaluate.cardType}`);
}
baseScore += CARD_SCORE_MAP[handType];
// console.log(`score: ${baseScore} * ${CARD_MULTIPLIER_MAP[handType]} = ${score}`);
return {
baseScore,
multiplier: CARD_MULTIPLIER_MAP[handType],
handType: handEvaluate.cardType,
validCards: this.serializeCards(validCards),
};
}
五、如何实现单局操作与回合结算
1. 玩家出牌 / 弃牌的流程
在这一阶段,玩家的操作不再只是一次性的接口调用,而是直接影响游戏状态。
selectCards 接口统一处理玩家出牌和弃牌操作,流程如下:
首先校验玩家状态和输入:
- 玩家是否存在
- 游戏是否处于进行中
selectedCards是否为空、重复、格式错误action是否为play或discard- 出牌 / 弃牌次数是否足够
对于 play 操作:
- 调用
calculateHandScore获取本次操作的得分 - 累加到
totalScore,并更新currentActionScore
对于 discard 操作:
- 不计分,但仍会扣减弃牌次数
最后返回更新后的状态给前端,包括:
- 当前手牌
- 剩余出牌 / 弃牌次数
- 当前操作得分
- 累计总分
gameOver标志settlement信息(若游戏结束)
通过这个统一接口,玩家的每一次操作都能够对游戏状态产生持续影响。
2. 自动补牌与手牌排序
每次出牌或弃牌操作完成后,服务端会自动补充手牌:
- 移除玩家已出的牌
- 从
deck中抽取相应数量补入 - 将手牌按
rank排序(A 最大,2 最小)
这样保证:
- 玩家手牌数量恒定
- 前端展示一致
- 游戏状态保持可预测
手牌排序的实现依赖 getCardRank 方法,可以快速判断牌面大小。
ts
public getCardRank(card: string): number {
return this.parseCard([card])[0].rank;
}
3. 判断游戏结束与生成结算信息
单局结束条件包括:
- 出牌次数用完 (
playsLeft = 0) - 或总分达到目标分数 (
totalScore >= targetScore)
结束后:
- 设置
playerState.gameStatus = "finished" gameOver标志返回前端,用于触发结算弹窗或展示胜负结果- 生成
settlement结构:
ts
settlement?: {
finalScore: playerState.score,
targetScore: playerState.targetScore,
result: "WIN" | "LOSE"
}
这一机制确保了单局操作的闭环完整。
4. 前端返回的数据结构设计
返回给前端的 DTO PlayCardsResult 包含:
hand: 当前手牌playsLeft/discardsLeftremainingDeckCount: 剩余牌堆数量currentActionScore/totalScorevalidCards/cardType: 当前操作得分牌和牌型gameOver/settlement
注意:
- 内部
deck不直接返回,避免泄露信息 currentActionScore用于展示本次操作得分totalScore用于显示累积分数validCards用于前端知道哪些牌参与计分cardType用于展示牌型
5. 单局操作闭环概览
完整单局操作流程如下:
text
startGame
↓
selectCards(play / discard)
↓
calculateHandScore
↓
update GameState (hand, playsLeft, discardsLeft, score)
↓
check gameOver
↓
return PlayCardsResult + settlement
通过这一步,前几篇实现的离散接口最终串联成完整的单局游戏流程:
- 游戏有开始
- 玩家可以操作
- 出牌有得分
- 游戏结束有结算
这也是第五篇最核心的成果:第一次让后端真正管理完整单局生命周期。
六、总结
1. 当前阶段完成内容
✔ 完整实现单局游戏流程闭环
✔ startGame 替代 dealCards,成为游戏生命周期入口
✔ selectCards 接口统一处理出牌 / 弃牌操作
✔ 实现牌型分数计算,支持基础分 * 倍率
✔ 增加 validCards,明确哪些牌参与计分
✔ 服务端维护玩家核心状态:totalScore、currentActionScore、targetScore、gameStatus
✔ 完整重构单元测试,覆盖出牌、弃牌、得分和游戏结束判断
这套设计不仅实现了当前单局流程,也为后续复杂功能(Blind、关卡、Boss、Modifier 等)提供了基础。
2. 本阶段总结
在这一阶段,我们将原本离散的接口操作整合成单局可运行的游戏流程,形成了完整的生命周期管理:
- 使用
startGame初始化单局状态,包括手牌、牌堆、剩余出牌/弃牌次数和得分状态 - 将牌型判断与得分计算分离,
validCards支撑最终分数计算 - 服务端维护核心状态,客户端仅负责展示,保证数据安全和一致性
- DTO 设计清晰,隐藏内部
deck信息方便未来扩展房间、多玩家和缓存机制
这套设计不仅实现了当前单局流程,也为后续复杂功能(Blind、关卡、Boss、Modifier 等)提供了基础。
✌️ 到这里,第一阶段"单局游戏流程"算是真正跑通了 🎉
3. 为什么这一步重要
这一阶段完成后,项目不再只是实现接口功能,而是真正形成了单局游戏可运行闭环,具备:
- 玩家从开始到结束的完整交互流程
- 自动补牌和手牌排序
- 分数累加与胜负结算
- 状态驱动(State Driven)后端设计
这意味着项目从单纯功能实现,升级为可扩展、可维护的游戏后端系统,为后续阶段的玩法扩展和多人房间打下了坚实基础。
4. 第一阶段正式完成✅
从第一篇的牌型判断开始,到这一篇完成单局结算流程。
目前已经实现:
- 发牌
- 出牌
- 弃牌
- 补牌
- 得分计算
- 状态维护
- 单局结算
- WebSocket 调用
- 服务端生命周期管理
到这里,一个最基础的 Balatro 单局游戏后端,终于真正跑通了。
回头再看第一篇时,其实会发现:最开始只是想先把牌型判断写出来。
但随着项目推进,接口开始逐渐关联,状态开始逐渐累积,整个系统也开始从"功能实现"慢慢变成真正的"游戏后端"。WebSocket 也不再只是一个测试入口,而是真正开始承载完整游戏流程。。
这也是这个系列目前为止,我觉得最有成就感的一步。
因为从这里开始,后面的内容就不再只是"把功能补齐"。
而是真正进入:
BlindBossModifier- 多回合
- 多玩家
- 状态同步
这些更接近完整游戏系统的部分。
5. 下一步计划
下一阶段将开始实现阶段 2 的功能:
Blind/Stage系统设计- 回合推进与目标分数递增
- 为
Boss/Modifier/ 多玩家扩展做准备
届时,单局流程会被嵌入更复杂的游戏机制,实现连续回合和关卡玩法。