从0到1实现Balatro游戏后端(5):得分计算与单局结算流程实现

49 阅读12分钟

这篇文章开始一个新的长期系列:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目

  • balatro-realtime-backend -> GitHub地址GitCode地址- GitHub地址: balatro-realtime-backend
    • 📌 本文对应代码分支:origin/feature/scoring-settlement
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。

📚 系列文章:

(1)🔗 项目规划与牌型判断实现
(2)🔗 NestJS框架搭建与项目结构设计
(3)🔗 洗牌、发牌与服务端牌堆状态管理
(4)🔗 玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计
(5)得分计算与单局结算流程实现(当前)✅
(6)Blind关卡状态设计与回合推进实现
(7)Boss Blind与特殊规则实现
(8)跳过Blind奖励与风险机制设计(开发中)


✅ 本篇实现了什么

前四篇里,我已经实现了:

  • 发牌
  • 出牌
  • 弃牌
  • 补牌

但这些能力其实还是一个个独立功能。

系统仍然回答不了几个关键问题:

  • 玩家出了这手牌得多少分?
  • 当前累计分数是多少?
  • 游戏什么时候结束?
  • 最终是赢还是输?

所以这一篇开始,我把这些离散能力真正串成了一局完整游戏流程。

到这里,项目第一次具备了完整的单局闭环。

一、为什么第五篇开始,项目开始像“游戏”了

在前几篇中,我已经陆续实现了发牌、出牌、弃牌、补牌这些基础行为。

但从严格意义来讲,这些能力还只是一个个相对独立的功能点。玩家可以拿到手牌,也可以从手牌中选择牌进行出牌或弃牌,服务端也能根据操作移除用户手牌以及补牌操作。

但是截止到这里为止,系统还不能回答更关键的几个问题:

  • 这一局的目标是什么?

  • 玩家选择出的牌得了多少分?

  • 当前累计分数是多少?

  • 游戏什么时候结束?

  • 结束之后。玩家是赢了还是输了?

也就是说,前面的实现更多的是在解决“玩家能做什么操作”,而这一篇开始则要解决的是:这些操作如何被串成一局完整的游戏流程

所以在这一篇中,我开始补上得分计算与单局结算相关的逻辑。

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

也就是说:得分已经开始成为“游戏状态”的一部分。

所以从这一篇开始,我将:

  • totalScore

  • currentActionScore

  • targetScore

  • gameStatus

全部纳入了服务端的 GameState 中统一维护。

3. 为什么游戏状态必须由服务端维护

这样设计最核心的原因是:服务端需要始终拥有完整且唯一的游戏状态

客户端只负责

  • 发起操作

  • 展示结果

真正的游戏数据则全部由服务端负责维护与计算:

  • 分数

  • 剩余次数

  • 游戏状态

  • 胜负结果

这样保证:

  • 状态唯一

  • 后续扩展房间、BlindBuffModifierRedis 缓存时一致性

  • 游戏逻辑可靠,不受客户端修改干扰

所以这一篇实际上不仅仅只是加了一个得分功能。

更重要的是:项目开始真正进入“状态驱动(State Driven)”的设计阶段


四、如何将牌型规则接入得分系统

1. 牌型判断并不等于得分计算

在之前的章节中,我们已经在 poker 模块中实现了牌型判断方法 getCardType(),它的职责是判断玩家手中的牌属于哪种牌型。

然而,牌型判断只是计算牌型得分的基础,并不是最终结果。要得到最终分数,还需要知道:

  • 每种牌型的基础分是多少

  • 每种牌型对应的分数倍率是多少

  • 哪些牌才真正参与得分

也就是说,得分计算是在牌型判断的基础上进行的二次处理,两者的职责需要明确拆分。

2. 使用配置映射管理牌型分数

其实不管是基础分是多少,还是对应牌型的倍率是多少,都是简单的数字,甚至可以写到代码中,直接判断:


if(straightFlush) {
    socre = (100 + 牌的本身分数) * 8
}

不需要列出额外的参数,可以少些很多行代码。这种写法短期可行,但存在几个问题:

  • 如果多处都直接写 (100 + …) * 8,未来维护代码时容易混乱

  • 不方便全局查看某个牌型的基础分或倍率

  • 稍有疏忽,可能导致不同地方使用的规则不一致

因此,我们把牌型的基础分和对应倍率,写入对应配置中:


export const CARD_SCORE_MAP: Record<HandType, number> = { ... };

export const CARD_MULTIPLIER_MAP: Record<HandType, number> = { ... };

这样做的好处是

  • 全局可查、统一管理;

  • 维护和修改更加方便;

  • 后续扩展特殊牌型或倍率时,只需修改配置,而不必到处改逻辑。

3. 为什么增加 validCards

最初的 getCardType() 只返回了一个 number 来表示牌型类型。

但在实际计算得分时,我们发现:

  • 并非玩家手中的所有牌都参与计算

  • 只有组成牌型的牌才参与基础分和倍率计算

如果每次都在牌型判断后二次去找哪些牌参与得分,会造成重复计算和逻辑混乱。

因此,我们对 getCardType() 的返回值进行了优化,新增了 validCards: Card[] 字段,用于明确标识哪些牌是有效的得分牌。

这样:

  • 牌型判断和得分计算职责明确分离

  • calculateHandScore() 可以直接使用 validCards 进行分数计算

  • 逻辑清晰、可扩展性更好

4. calculateHandScore实现


public calculateHandScore(cards: string[]): {
        baseScore: number;
        multiplier: number;
        handType: number;
        validCards: string[];
    } {

        const handEvaluate: HandEvaluateResult = this.getCardType(cards);
        const validCards = handEvaluate.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];

        return {
            baseScore,
            multiplier: CARD_MULTIPLIER_MAP[handType],
            handType: handEvaluate.cardType,
            validCards: this.serializeCards(validCards),
        };
}

五、如何实现单局操作与回合结算

1. 玩家出牌 / 弃牌的流程

在这一阶段,玩家的操作不再只是一次性的接口调用,而是直接影响游戏状态。

selectCards 接口统一处理玩家出牌和弃牌操作,流程如下:

首先校验玩家状态和输入:

  • 玩家是否存在

  • 游戏是否处于进行中

  • selectedCards 是否为空、重复、格式错误

  • action 是否为 playdiscard

  • 出牌 / 弃牌次数是否足够

对于 play 操作:

  • 调用 calculateHandScore 获取本次操作的得分

  • 累加到 totalScore,并更新 currentActionScore

对于 discard 操作:

  • 不计分,但仍会扣减弃牌次数

最后返回更新后的状态给前端,包括:

  • 当前手牌

  • 剩余出牌 / 弃牌次数

  • 当前操作得分

  • 累计总分

  • gameOver 标志

  • settlement 信息(若游戏结束)

通过这个统一接口,玩家的每一次操作都能够对游戏状态产生持续影响。

2. 自动补牌与手牌排序

每次出牌或弃牌操作完成后,服务端会自动补充手牌:

  • 移除玩家已出的牌

  • deck 中抽取相应数量补入

  • 将手牌按 rank 排序(A 最大,2 最小)

这样保证:

  • 玩家手牌数量恒定

  • 前端展示一致

  • 游戏状态保持可预测

手牌排序的实现依赖 getCardRank 方法,可以快速判断牌面大小。


public getCardRank(card: string): number {

return this.parseCard([card])[0].rank;

}

3. 判断游戏结束与生成结算信息

单局结束条件包括:

  • 出牌次数用完 (playsLeft = 0)

  • 或总分达到目标分数 (totalScore >= targetScore)

结束后:

  • 设置 playerState.gameStatus = "finished"

  • gameOver 标志返回前端,用于触发结算弹窗或展示胜负结果

  • 生成 settlement 结构:


settlement?: {

finalScore: playerState.score,

targetScore: playerState.targetScore,

result: "WIN" | "LOSE"

}

这一机制确保了单局操作的闭环完整。

4. 前端返回的数据结构设计

返回给前端的 DTO PlayCardsResult 包含:

  • hand: 当前手牌

  • playsLeft / discardsLeft

  • remainingDeckCount: 剩余牌堆数量

  • currentActionScore / totalScore

  • validCards / cardType: 当前操作得分牌和牌型

  • gameOver / settlement

注意:

  • 内部 deck 不直接返回,避免泄露信息

  • currentActionScore 用于展示本次操作得分

  • totalScore 用于显示累积分数

  • validCards 用于前端知道哪些牌参与计分

  • cardType 用于展示牌型

5. 单局操作闭环概览

完整单局操作流程如下:


startGame

↓

selectCards(play / discard)

↓

calculateHandScore

↓

update GameState (hand, playsLeft, discardsLeft, score)

↓

check gameOver

↓

return PlayCardsResult + settlement

通过这一步,前几篇实现的离散接口最终串联成完整的单局游戏流程:

  • 游戏有开始

  • 玩家可以操作

  • 出牌有得分

  • 游戏结束有结算

这也是第五篇最核心的成果:第一次让后端真正管理完整单局生命周期


六、总结

1. 当前阶段完成内容

✔ 完整实现单局游戏流程闭环

startGame 替代 dealCards,成为游戏生命周期入口

selectCards 接口统一处理出牌 / 弃牌操作

✔ 实现牌型分数计算,支持基础分 * 倍率

✔ 增加 validCards,明确哪些牌参与计分

✔ 服务端维护玩家核心状态:totalScorecurrentActionScoretargetScoregameStatus

✔ 完整重构单元测试,覆盖出牌、弃牌、得分和游戏结束判断

这套设计不仅实现了当前单局流程,也为后续复杂功能(Blind、关卡、BossModifier 等)提供了基础。

🎉 到这里,第一阶段“单局游戏流程”算是真正跑通了。

2. 为什么这一步重要

这一阶段完成后,项目不再只是实现接口功能,而是真正形成了单局游戏可运行闭环,具备:

  • 玩家从开始到结束的完整交互流程

  • 自动补牌和手牌排序

  • 分数累加与胜负结算

  • 状态驱动(State Driven)后端设计

这意味着项目从单纯功能实现,升级为可扩展、可维护的游戏后端系统,为后续阶段的玩法扩展和多人房间打下了坚实基础。

3. 下一步计划

下一阶段将开始实现阶段 2 的功能:

  • Blind / Stage 系统设计

  • 回合推进与目标分数递增

  • Boss / Modifier / 多玩家扩展做准备

届时,单局流程会被嵌入更复杂的游戏机制,实现连续回合和关卡玩法。