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

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

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

✅ 本篇实现了什么

在这一篇中,我完成了让游戏真正跑起来的核心功能:

  • 完整的单局游戏闭环,从开始到结算
  • dealCards 升级为 startGame,成为单局生命周期入口
  • 玩家出牌 / 弃牌统一接口 selectCards,支持分数计算
  • 牌型分数计算与倍率逻辑,并增加 validCards 标识参与计分的牌
  • 服务端维护核心状态:totalScorecurrentActionScoretargetScoregameStatus
  • 单元测试覆盖出牌、弃牌、得分计算以及游戏结束判断

简单来说,这一篇完成后,就以从开始到结束完整体验一局游戏了。

文章目录

  • 一、为什么第五篇开始,项目开始像"游戏"了
  • [二、把 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

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

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

  • totalScore
  • currentActionScore
  • targetScore
  • gameStatus

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

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

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

客户端只负责

  • 发起操作
  • 展示结果

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

  • 分数
  • 剩余次数
  • 游戏状态
  • 胜负结果

这样保证:

  • 状态唯一
  • 后续扩展房间、BlindBuffModifierRedis 缓存时一致性
  • 游戏逻辑可靠,不受客户端修改干扰

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

更重要的是:项目开始真正进入"状态驱动(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 会怎么样

如果不返回 validCardscalculateHandScore 则需要再次分析哪些牌参与计分。

这样会导致:

  • 重复计算
  • 牌型模块和得分模块耦合
  • 后续特殊牌规则扩展困难

因此最终选择在牌型判断阶段直接返回 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 是否为 playdiscard
  • 出牌 / 弃牌次数是否足够

对于 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 / discardsLeft
  • remainingDeckCount: 剩余牌堆数量
  • currentActionScore / totalScore
  • validCards / 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,明确哪些牌参与计分

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

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

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

2. 本阶段总结

在这一阶段,我们将原本离散的接口操作整合成单局可运行的游戏流程,形成了完整的生命周期管理:

  • 使用 startGame 初始化单局状态,包括手牌、牌堆、剩余出牌/弃牌次数和得分状态
  • 将牌型判断与得分计算分离,validCards 支撑最终分数计算
  • 服务端维护核心状态,客户端仅负责展示,保证数据安全和一致性
  • DTO 设计清晰,隐藏内部 deck 信息方便未来扩展房间、多玩家和缓存机制

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

✌️ 到这里,第一阶段"单局游戏流程"算是真正跑通了 🎉

3. 为什么这一步重要

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

  • 玩家从开始到结束的完整交互流程
  • 自动补牌和手牌排序
  • 分数累加与胜负结算
  • 状态驱动(State Driven)后端设计

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

4. 第一阶段正式完成✅

从第一篇的牌型判断开始,到这一篇完成单局结算流程。

目前已经实现:

  • 发牌
  • 出牌
  • 弃牌
  • 补牌
  • 得分计算
  • 状态维护
  • 单局结算
  • WebSocket 调用
  • 服务端生命周期管理

到这里,一个最基础的 Balatro 单局游戏后端,终于真正跑通了。

回头再看第一篇时,其实会发现:最开始只是想先把牌型判断写出来。

但随着项目推进,接口开始逐渐关联,状态开始逐渐累积,整个系统也开始从"功能实现"慢慢变成真正的"游戏后端"。WebSocket 也不再只是一个测试入口,而是真正开始承载完整游戏流程。。

这也是这个系列目前为止,我觉得最有成就感的一步。

因为从这里开始,后面的内容就不再只是"把功能补齐"。

而是真正进入:

  • Blind
  • Boss
  • Modifier
  • 多回合
  • 多玩家
  • 状态同步

这些更接近完整游戏系统的部分。

5. 下一步计划

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

  • Blind / Stage 系统设计
  • 回合推进与目标分数递增
  • Boss / Modifier / 多玩家扩展做准备

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

相关推荐
减瓦2 小时前
Jackson 自定义反序列化器的类型不匹配陷阱
java·后端
HLAIA光子2 小时前
计网面试躲不掉的三连问:OSI七层、HTTPS握手、REST还是RPC
后端·网络协议
JAVA社区2 小时前
Java高级全套教程(九)—— SpringCloud超详细实战详解
java·开发语言·后端·spring cloud·面试·职场和发展
yspwf2 小时前
Electron/Node 本地集成 C#/.NET,node-api-dotnet
后端
万少2 小时前
Claude Code 任务结束会自己喊你:一个 Stop Hook 搞定提示音
前端·后端·代码规范
仙俊红2 小时前
spring有多个对象时如何注入
java·后端·spring
追光者♂2 小时前
【测评系列3】CSDN AI数字营销实测体验官:测试 开源项目——Superpowers 游戏引擎从零开发实战指南
人工智能·深度学习·机器学习·typescript·开源·游戏引擎·superpowers
ct9782 小时前
TypeScript 中的泛型
前端·javascript·typescript
Java爱好狂.2 小时前
Redis高级笔记:深入浅出Java面试高频考点!
java·数据库·redis·后端·java面试·java程序员·java八股文