从0到1实现Balatro游戏后端(9):Blind奖励结算与金币系统实现

本系列记录我从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。

  • 该项目的代码已开源,可在 GitHubGitCode 上获取。
    • 📌 本文对应代码分支:origin/feature/blind-reward-money
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。

✅ 本篇实现了什么

前面的几篇主线里,我一直在处理:出牌、算分、Blind 推进、Boss 规则。

但当 Blind 生命周期跑通以后,我发现项目里始终缺少一个东西:

玩家打赢一关之后,到底获得了什么?

而这个问题,最终把项目从"关卡推进"带到了"资源成长"。所以本篇开始进入第三阶段:🎯 奖励、商店与构筑成长系统

在这一篇中,我先从最基础的资源开始,引入 money 状态,并完成 Blind 胜利后的金币奖励结算。

本篇主要实现:

  • PlayerState 增加 money 字段
  • Blind 配置增加 baseRewardMoney
  • 新增 economy.config.ts 管理通用经济规则
  • 设计 RewardMoneyDetail 奖励明细
  • Blind 胜利后结算基础奖励、剩余出牌奖励和利息奖励
  • 将本次奖励返回到 progress.settlement.reward
  • 将玩家总金币更新到 playerState.money

这一篇代码改动不算多,但它让游戏流程开始从"关卡推进"进入到"资源成长"。

后续的商店、购买、刷新、JokerTag 和构筑成长系统,都会建立在这个 money 状态之上。

一、为什么第三阶段不再先做持久化

第二阶段正式结束,第三阶段现在开始🎉。

不过在项目最初计划的时候,第三阶段预期是"持久化与缓存分层"。但当项目持续推进到了第三阶段的时候,我发现这里需要推翻一下曾经的规划。

1.1 为什么暂时不做持久化

因为当前的项目正处于:

玩法主循环刚跑起来,但玩家成长系统还没出现

现在确实已经有了 GameState,也有了 PlayerStateBlindStateBoss BlindSkip BlindTag 等状态。

但这些状态目前更多还是围绕整局流程、关卡推进、规则判断在运转。

如果这个时候直接进入 Redis / MySQL,不是不能做,但多少会有一点"为了推进阶段而推进阶段"的感觉。

所以不是 Redis / MySQL 不重要,而是

现在还没有足够值得持久化的数据。

对于这个项目来说,我更希望每个阶段的推进都是顺着游戏流程自然长出来的,而不是为了把技术栈堆上去。

1.2 第三阶段真正要补的中间层

所以实际的第三阶段,我会继续推进玩法节奏,目标是:

让玩家从"打过一关"变成"每一关之后都能变强"

现在的后端已经有了:打 Blind -> 结算胜负 -> 进入下一个 Blind / Ante

但还缺 Balatro 最核心的中间层:打 Blind / Skip Blind -> 获得奖励 -> 进入商店 -> 购买 / 跳过 / 刷新 -> 构筑变化 -> 进入下一个 Blind / Ante

用流程图表示,大概就是下面这样:

flowchart LR A[打 Blind] --> B[结算胜负] B --> C[直接进入下一个 Blind / Ante] B --> D[获得奖励] D --> E[进入商店] E --> F[购买 / 跳过 / 刷新] F --> G[构筑发生变化] G --> H[进入下一个 Blind / Ante] C -.第三阶段不再直接跳过成长链路.-> D style C fill:#f5f5f5,stroke:#999,stroke-dasharray: 5 5 style D fill:#fff7e6 style E fill:#fff7e6 style F fill:#fff7e6 style G fill:#fff7e6

也就是说,第三阶段的方向会调整为:🎯 奖励、商店与构筑成长系统

原本第三阶段计划中的 "持久化与缓存分层",会延迟到第四阶段再处理。

而本阶段的第一步,我会先从最基础、也最容易忽略的资源开始: 👉 Blind 奖励结算与金币状态设计

二、为什么先从 money 开始

很多游戏项目里,金币看起来只是一个数字。但真正开始做后端时,我发现: money 并不是一个字段,而是一切成长系统的入口

这一篇的代码改动其实不会特别多。

从实际上看,无非是给 PlayerState 增加 money 字段,再在 Blind 通过后,根据基础奖励、剩余出牌次数、利息进行累加。

但我还是决定把它单独拆出来。

因为从这一篇开始,游戏流程不再只是:

玩家打牌 -> 计算得分 -> 判断是否通过 Blind

而是多了一步:

玩家通过 Blind -> 获得奖励 -> 更新资源状态

这个变化看起来很小,但它是后续商店、JokerTag、构筑成长系统的基础。

不过在真正实现之前,还有一个接口层面的取舍需要先想清楚:这次金币奖励,是否需要单独设计一个"提取奖励"的接口?

三、为什么暂时不单独设计"提取奖励"接口

在实际游戏中,Blind 通过后并不是立刻进入到下一个 Ante / Blind,而是先会进入金币结算页面。

这个页面会展示当前 Blind 的基础奖励、剩余出牌次数奖励、利息奖励。玩家点击"提取$"后,才会继续进入后续流程。

也就是说,从游戏表现上看,金币不是在胜利瞬间直接"消失式入账"的,而是有一个结算展示和确认过程。

但在当前后端阶段,我暂时没有单独增加 claimReward 接口的打算,因为当前项目还没有完整的 reward -> shop -> next blind 页面状态流转。

如果此时只是为了模拟"提取$"这个按钮,就单独增加一个接口,反而会提前引入一些新的状态复杂度。

回头看这里,我发现自己其实不是在决定:要不要写一个接口。

而是在决定:当前阶段,是否需要提前引入一套奖励状态机。比如说:

  • pendingReward:当前是否有待领取奖励
  • rewardStatus:奖励是否已经领取
  • 重复领取校验:同一份奖励不能被领取两次
  • 阶段限制:只有在 reward 状态下才能领取奖励

这些逻辑后面肯定会有价值,但放在当前这一篇里,会让主题从"Blind 奖励结算与金币状态设计"扩散到"奖励状态机设计"

所以本篇先采用一个简化设计:

selectCards 判断当前 Blind 通过时,后端立即完成 reward 计算,并更新 playerState.money,同时在 settlement.reward 中返回本次奖励明细。

这样前端仍然可以根据 settlement.reward 知道本次 Blind 相关的 money 数据。

只是在当前阶段,"提取$"更像是 UI 层面的仪式感,而不是后端真正的领取动作。

等后续进入商城系统,真正出现 reward -> shop -> next blind 的状态流转后,再把这里升级成独立的 claimReward / continueAfterReward 接口,会更自然。

四、玩家金币状态应该放在哪里

既然本篇要引入 money,那么第一个问题肯定就是:

哪些数据应该作为玩家状态长期存在,哪些数据只是本次结算时临时产生的结果?

一开始看起来,所有和金币相关的数据都可以直接放在返回结果里。

比如当前玩家一共有多少钱、本次 blind 获得了多少钱、基础奖励是多少、剩余出牌次数奖励是多少、利息又是多少。

但真正拆开之后会发现,它们其实不是同一类的数据。

4.1 money 属于 PlayerState

首先可以确定的是,玩家当前拥有的金币数量,是放在 PlayerState 中的。因为 money 不是某一次出牌的结果,也不是某一个 blind 的临时数据,而是玩家从开始游戏后持续累积的资产。

它后续会继续影响很多系统,比如:

  • 商城购买
  • 刷新商城
  • 购买 Joker
  • 购买补充包
  • 后续可能出现的 Tag / Voucher / Joker 经济效果

所以这次首先在 PlayerState 中增加了 money 字段用于记录玩家当前已经拥有的总金币数量

ts 复制代码
export type PlayerState = { 
    deck: Card[]; 
    hand: string[]; 
    playsLeft: number; 
    discardsLeft: number; 
    handSize: number; 
    totalScore: number; 
    currentActionScore: number; 
    money: number; 
    };

4.2 本次奖励明细不属于常驻状态

在实现过程中,我一开始也考虑过在返回结构中增加类似 currentBlindMoney 的字段,用来表示当前 blind 获得了多少金币。

但后来发现这个字段并不适合放在常规的 GameStateResponse 中。因为它并不是一个长期存在的玩家状态,而是一次 blind 结束后产生的结算结果。

如果把它直接放在 GameStateResponse 里,就会让返回结构里同时混着两类数据:

  • 当前玩家状态
  • 本次结算事件

这样就会让参数的内容变得松散。所以本次没有在 GameStateResponse 额外增加 currentBlindMoney

4.3 使用 settlement.reward 承载本次奖励

本次 blind 获得的金币明细,最终放在了 progress.settlement.reward 中,也就是:

ts 复制代码
export type Progress = {
    gameOver: boolean;
    blindOver: boolean;
    settlement?: {
        finalScore: number;
        targetScore: number;
        result: "WIN" | "LOSE";
        reward?: RewardMoneyDetail;
    };
    ...
};

这样含义就会更清楚一些,如果从后端角度去理解:

  • playerState.money:玩家当前总金币,属于运行时资源状态(Runtime Resource State)。
  • progress.settlement.reward:本次 blind 结算获得的金币明细,属于一次结算事件(Settlement Event)。

两者虽然都和金币有关,但生命周期完全不同。

也就是说,状态和事件结果被分开了。玩家身上只保存最终资源状态,而本次结算过程中的奖励拆分,则交给 settlement.reward 返回给前端展示。

这样前端既可以直接拿到玩家当前的总金币,也可以在结算页面中展示:

  • 当前 blind 基础奖励
  • 剩余出牌次数奖励
  • 利息奖励
  • 本次总奖励
  • 结算后的金币数量
数据 所属位置 生命周期 作用说明
playerState.money PlayerState 整局游戏持续存在 记录玩家当前拥有的总金币
baseMoney settlement.reward 本次 Blind 结算期间 当前 Blind 的基础奖励
remainingHandBonusMoney settlement.reward 本次 Blind 结算期间 剩余出牌次数带来的额外奖励
interestMoney settlement.reward 本次 Blind 结算期间 根据结算前金币计算出的利息
currentBlindRewardMoney settlement.reward 本次 Blind 结算期间 本次 Blind 总共获得的金币
moneyAfterReward settlement.reward 本次 Blind 结算期间 用于展示结算后的金币数量

这个拆分也让后续扩展更自然。以后如果 TagJokerVoucher 影响金币,也可以继续追加到奖励明细中,而不是往顶层返回结构里继续塞字段。

五、金币奖励规则应该如何配置

确定了金币状态放在哪里之后,接下来要解决的是第二个问题:

金币奖励规则应该放在哪里?

本篇涉及到的金币规则主要有三类:

  • 当前 Blind 的基础奖励
  • 剩余出牌次数奖励
  • 利息奖励

虽然它们最后都会影响 playerState.money,但它们的来源并不一样。

5.1 baseRewardMoney 放在 blind.config.ts

首先是当前 blind 的基础奖励,比如:

  • small blind: 基础奖励 3
  • big blind: 基础奖励 4
  • boss blind: 基础奖励 5

这类数据本质上是属于当前 blind 自己的配置,因为不同 blind 本身就有不同的目标分、类型、奖励信息,所以我把 baseRewardMoney 放在了 blind.config.ts 中。

ts 复制代码
export const BLIND_SCORE_CONFIG = {
    1: [
        { type: "small", score: 150, baseRewardMoney: 3 },
        { type: "big", score: 250, baseRewardMoney: 4 },
        { type: "boss", score: 350, baseRewardMoney: 5 },
    ]...

这样设计后,blind 的目标分和基础奖励会放在同一个配置中。

后续如果某个 ante 下的 blind 奖励发生变化,也可以直接在 blind.config.ts 中调整,而不是散落到其他规则文件里。

5.2 通用经济规则放在 economy.config.ts

除了 baseRewardMoney 以外,还有一些规则并不属于某一个具体的 blind。比如:

  • 玩家初始 money 是多少
  • 每剩余一次出牌次数,奖励多少 money
  • 每多少 money 可以获得 1 点利息
  • 利息最多可以获得多少

这些规则虽然会在 blind 结算时使用,但它们不是某个 blind 独有的配置,而是整个经济系统的通用规则。

一开始我也考虑把这些内容直接放进 game.constants.ts。但再往后想一层,本阶段的整体方向是:奖励、商店与构筑成长系统。

也就是说,后续商店系统会逐渐出现,而金币会成为商店购买、刷新、构筑成长的基础资源。

所以本篇没有继续把这些规则塞进 game.constants.ts,而是单独新增了一个配置文件: economy.config.ts

当前暂时只有金币相关的简单配置:

ts 复制代码
export const ECONOMY_RULE = {
    INITIAL_MONEY: 0,
} as const;

export const BLIND_REWARD_RULE = {
    REMAINING_PLAY_BONUS_MONEY: 1,
} as const;

export const INTEREST_RULE = {
    MONEY_PER_INTEREST: 5,
    MAX_INTEREST: 5
} as const;
  • 为了让规则归属更清楚,这里先简单整理一下:
规则 放置位置 原因
baseRewardMoney blind.config.ts 属于不同 Blind 自身的基础配置
INITIAL_MONEY economy.config.ts 属于玩家经济系统的初始规则
REMAINING_PLAY_BONUS_MONEY economy.config.ts 属于通用奖励结算规则
MONEY_PER_INTEREST economy.config.ts 属于金币利息计算规则
MAX_INTEREST economy.config.ts 属于经济系统的收益上限规则

这样拆分之后,职责会更清楚:

  • blind.config.ts:描述每个 blind 自己的基础配置
  • economy.config.ts:描述金币如何产生、如何结算、后续如何消费

这里并不是为了一个 INITIAL_MONEY 就强行新建文件,而是因为第三阶段后续会持续围绕 money 展开。

当前阶段 economy.config.ts 里的内容还比较少,但它后面可以继续承接商店刷新费用、购买价格、折扣规则、出售规则等经济相关配置。

也就是说,本篇虽然只做了 money 的获得,但配置文件先按照"经济系统"的方向拆出来,后续扩展会更自然。

六、Blind 奖励明细与结算流程设计

前面已经确定了 money 放在 PlayerState,奖励规则放在配置文件里。接下来要做的就是:当一个 blind 结束并且玩家胜利时,后端应该如何描述这次的金币奖励。

最简单的做法其实是只返回一个总数:rewardMoney: 6

但这样前端只能知道"这次一共获得了多少钱",却不知道这些钱分别来自哪里。

而在实际结算中,金币来源是可以拆开的:

  • 当前 blind 的基础奖励
  • 剩余出牌次数奖励
  • 当前已有金币产生的利息

所以本次没有只返回一个 number,而是把本次奖励拆成了一个明细对象。

6.1 RewardMoneyDetail 设定

本次奖励明细使用的是 RewardMoneyDetail 表示:

ts 复制代码
export type RewardMoneyDetail = {
    baseMoney: number;
    remainingHandBonusMoney: number;
    interestMoney: number;
    currentBlindRewardMoney: number;
    moneyAfterReward: number;
};

其中:

  • baseMoney:当前 blind 的基础奖励
  • remainingHandBonusMoney:剩余出牌次数奖励
  • interestMoney:根据当前已有金币计算出的利息
  • currentBlindRewardMoney:本次 blind 总共获得的金币
  • moneyAfterReward:结算后玩家身上一共有多少金币

🎯 Blind 胜利后的奖励结算链路:

flowchart LR A[baseMoney<br/>Blind 基础奖励] --> D[currentBlindRewardMoney<br/>本次总奖励] B[remainingHandBonusMoney<br/>剩余出牌奖励] --> D C[interestMoney<br/>利息奖励] --> D E[playerState.money<br/>结算前金币] --> F[moneyAfterReward<br/>结算后金币] D --> F style A fill:#fff7e6,stroke:#d48806,color:#000 style B fill:#fff7e6,stroke:#d48806,color:#000 style C fill:#fff7e6,stroke:#d48806,color:#000 style D fill:#f6ffed,stroke:#389e0d,color:#000 style E fill:#f0f5ff,stroke:#597ef7,color:#000 style F fill:#f6ffed,stroke:#389e0d,color:#000

这样前端展示结算页面时,就不需要自己反推金币来源了。

6.2 奖励计算发生在 Blind 胜利之后

奖励结算不是每次出牌都会发生,而是只会发生在当前 blind 结束,并且玩家胜利之后。

🎯 RewardMoneyDetail 数据流转关系:

flowchart LR A[selectCards] --> B[计算本次得分] B --> C{Blind 是否结束?} C -- 否 --> D[返回当前状态<br/>不结算 reward] C -- 是 --> E{结果是否为 WIN?} E -- LOSE --> F[返回失败结算<br/>不返回 reward] E -- WIN --> G[计算 reward] G --> H[更新 playerState.money] H --> I[返回 settlement.reward]

对应代码中,我单独抽出了 calculateBlindRewardDetail 方法:

ts 复制代码
    private calculateBlindRewardDetail(blindState: BlindState, playerState: PlayerState): RewardMoneyDetail {
        const blindType = blindState.blindType;
        const playsLeft = playerState.playsLeft;
        const baseMoney = blindState.currentAnteConfig[blindType].baseRewardMoney;

        const remainingHandBonusMoney = playsLeft * BLIND_REWARD_RULE.REMAINING_PLAY_BONUS_MONEY;
        const blindRewardMoney = baseMoney + remainingHandBonusMoney;

        const interestMoney = Math.min(Math.floor(playerState.money / INTEREST_RULE.MONEY_PER_INTEREST), INTEREST_RULE.MAX_INTEREST);

        const currentBlindRewardMoney = blindRewardMoney + interestMoney;

        return {
            baseMoney,
            remainingHandBonusMoney: remainingHandBonusMoney,
            interestMoney: interestMoney,
            currentBlindRewardMoney: currentBlindRewardMoney,
            moneyAfterReward: playerState.money + currentBlindRewardMoney,
        };
    }

这个方法只负责计算奖励明细,不直接修改 gameState。 这样 resolveProgressAfterBlind 只需要关心:当前 blind 胜利后,生成奖励明细,并且把奖励应用到玩家状态上。

6.3 利息按结算前 money 计算

这里还有一个小细节:interestMoney 是按照本次奖励入账前的 playerState.money 计算的。

也就是说,如果玩家当前有 10 个金币,本次 blind 基础奖励 3,剩余出牌奖励 2,那么利息会先按照 10 个金币计算,而不是先加到 15 后再计算。

当前规则是:

ts 复制代码
 interestMoney = Math.floor(playerState.money / 5)

并且最多不超过 INTEREST_RULE.MAX_INTEREST

我这里这样处理,是因为利息更像是"已有金币产生的收益",而不是本次刚获得的奖励立刻再次产生收益。

6.4 输掉 Blind 时不返回 reward

最后还有一个边界:如果玩家输掉了当前 blind,后端不应该返回 reward

所以 settlement.reward 被设计成可选字段:

ts 复制代码
export type Progress = {
    gameOver: boolean;
    blindOver: boolean;
    settlement?: {
        finalScore: number;
        targetScore: number;
        result: "WIN" | "LOSE";
        reward?: RewardMoneyDetail;
    };

    currentAnteConfig: AnteConfig;
    nextAnteConfig?: AnteConfig;
    nextBlindConfig?: NextBlindConfig;
};

这样语义会更清楚:

  • WIN:返回 reward
  • LOSE:只返回失败结算结果

失败不是"获得了 0 奖励",而是"没有进入奖励结算"。

6.5 返回结构上的一点取舍

因此,本篇最终只保留两类金币数据:

  • playerState.money:玩家当前总金币
  • progress.settlement.reward:本次 Blind 的奖励明细

没有再额外增加 currentBlindMoney 这类顶层字段。

因为它和 settlement.reward.currentBlindRewardMoney 表达的是同一件事,继续保留反而会让数据来源变得分散。

6.6 当前验证结果

当前这版主要验证的是:Blind 胜利后,后端可以正确计算金币奖励,并把奖励明细返回给前端。

场景 条件 预期结果
Small Blind 胜利 baseRewardMoney = 3 返回基础奖励 baseMoney = 3
剩余出牌次数奖励 playsLeft = 2 返回 remainingHandBonusMoney = 2
利息奖励 结算前 playerState.money = 10 返回 interestMoney = 2
利息上限 结算前金币足够多 interestMoney 不超过 MAX_INTEREST
胜利结算 当前 Blind 通过 更新 playerState.money,并返回 settlement.reward
失败结算 当前 Blind 未通过 不返回 settlement.reward

也就是说,当前这版先验证的是:

Blind WIN -> 计算奖励明细 -> 更新 playerState.money -> 返回 settlement.reward 这条最小闭环。

回头看这一篇,我实现的其实不是一个简单的金币结算。而是第一次把:事件发生 -> 奖励计算 -> 资源状态更新 -> 事件结果返回 这一整条资源结算链路真正跑通。

七、本篇实现后的流程变化

本篇代码改动不算多,但它让 Blind 胜利后的流程多了一个关键节点:奖励结算

在此之前,Blind 胜利后更像是直接推进到下一个 Blind / Ante

而本篇之后,胜利分支中间多了一步:

flowchart LR A[Blind 胜利] --> B[奖励结算] --> C[更新玩家金币] --> D[返回奖励明细] --> E[进入下一阶段] A -.-> E style A fill:#f0f5ff,stroke:#597ef7,color:#000 style B fill:#fffbe6,stroke:#d48806,color:#000 style C fill:#fffbe6,stroke:#d48806,color:#000 style D fill:#fffbe6,stroke:#d48806,color:#000 style E fill:#f6ffed,stroke:#389e0d,color:#000

这个节点目前只处理金币,但它的意义并不只是 money += reward

因为从这里开始,玩家状态中开始出现可以被后续系统消费的资源。后面的商店、JokerTag、构筑成长,都会建立在这个资源变化之上。

八、总结

8.1 当前阶段完成内容

本篇完成了 Blind 胜利后的金币奖励结算设计。

主要新增了:

  • PlayerState.money:记录玩家当前总金币
  • baseRewardMoney:记录不同 Blind 的基础奖励
  • economy.config.ts:管理通用金币规则
  • RewardMoneyDetail:返回本次 Blind 的奖励明细

从这一篇开始,玩家通过 Blind 后不再只是推进到下一关,而是会获得可以持续积累的资源。

8.2 本篇设计重点

本篇最核心的设计,不是简单地给玩家加一个 money 字段,而是把金币相关数据拆成了两类:

  • playerState.money:玩家长期持有的资源状态
  • progress.settlement.reward:本次 Blind 结算产生的奖励结果

这样可以避免把"玩家当前状态"和"本次结算事件"混在一起。

同时,奖励结果也没有只返回一个总数,而是拆成基础奖励、剩余出牌奖励、利息奖励等明细,方便后续前端展示结算页面。

8.3 本篇流程变化

本篇让 Blind 胜利后的流程从:

打赢 Blind → 直接进入下一个阶段

变成了:

打赢 Blind → 结算奖励 → 更新 money → 进入下一个阶段

这个变化看起来不大,但它让玩家状态开始具备资源成长能力。

后续商店、购买、刷新、JokerTagVoucher 等系统,都可以继续围绕 money 展开。

8.4 下一步计划

本篇解决的是:

玩家打赢 Blind 后,资源如何产生?

下一步要继续解决:

玩家拿到金币后,可以在哪里花?

也就是开始进入商店、购买、刷新与构筑成长相关的设计。

相关推荐
Patrick_Wilson1 小时前
幂等到底是什么?从前端视角讲透 SQL、HTTP 与 POST 接口的幂等设计
前端·后端·架构
凌览1 小时前
一人公司别再上 Jenkins,真不值
前端·后端
菜鸟谢1 小时前
Rust 元组与数组内存管理笔记
后端
oil欧哟1 小时前
Codex 最佳实践(超级长文):先搞懂 AI,再用好 AI
前端·人工智能·后端
AskHarries1 小时前
把一个外部系统接成 MCP 工具
后端·程序员
释然小师弟2 小时前
Android开发十年:反思与回顾
android·后端·嵌入式
雪隐2 小时前
个人电脑玩AI-04让5060 Ti给你打工——本地FLUX.2 Klein 的 AI 图片生成
人工智能·后端
掘金者阿豪2 小时前
多台服务器日志怎么统一清理?Ansible、Cron与cpolar自动化方案
后端
浮游本尊3 小时前
Java学习第45天 - 消息队列入门、异步解耦与最终一致性(RabbitMQ / RocketMQ)
后端