本系列记录我从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。
- 该项目的代码已开源,可在 GitHub 与 GitCode 上获取。
- 📌 本文对应代码分支:
origin/feature/blind-reward-money - ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。
- 📌 本文对应代码分支:
✅ 本篇实现了什么
前面的几篇主线里,我一直在处理:出牌、算分、Blind 推进、Boss 规则。
但当 Blind 生命周期跑通以后,我发现项目里始终缺少一个东西:
玩家打赢一关之后,到底获得了什么?
而这个问题,最终把项目从"关卡推进"带到了"资源成长"。所以本篇开始进入第三阶段:🎯 奖励、商店与构筑成长系统。
在这一篇中,我先从最基础的资源开始,引入 money 状态,并完成 Blind 胜利后的金币奖励结算。
本篇主要实现:
PlayerState增加money字段Blind配置增加baseRewardMoney- 新增
economy.config.ts管理通用经济规则 - 设计
RewardMoneyDetail奖励明细 - 在
Blind胜利后结算基础奖励、剩余出牌奖励和利息奖励 - 将本次奖励返回到
progress.settlement.reward - 将玩家总金币更新到
playerState.money
这一篇代码改动不算多,但它让游戏流程开始从"关卡推进"进入到"资源成长"。
后续的商店、购买、刷新、Joker、Tag 和构筑成长系统,都会建立在这个 money 状态之上。
一、为什么第三阶段不再先做持久化
第二阶段正式结束,第三阶段现在开始🎉。
不过在项目最初计划的时候,第三阶段预期是"持久化与缓存分层"。但当项目持续推进到了第三阶段的时候,我发现这里需要推翻一下曾经的规划。
1.1 为什么暂时不做持久化
因为当前的项目正处于:
玩法主循环刚跑起来,但玩家成长系统还没出现。
现在确实已经有了 GameState,也有了 PlayerState、BlindState、Boss Blind、Skip Blind、Tag 等状态。
但这些状态目前更多还是围绕整局流程、关卡推进、规则判断在运转。
如果这个时候直接进入 Redis / MySQL,不是不能做,但多少会有一点"为了推进阶段而推进阶段"的感觉。
所以不是 Redis / MySQL 不重要,而是
现在还没有足够值得持久化的数据。
对于这个项目来说,我更希望每个阶段的推进都是顺着游戏流程自然长出来的,而不是为了把技术栈堆上去。
1.2 第三阶段真正要补的中间层
所以实际的第三阶段,我会继续推进玩法节奏,目标是:
让玩家从"打过一关"变成"每一关之后都能变强"
现在的后端已经有了:打 Blind -> 结算胜负 -> 进入下一个 Blind / Ante。
但还缺 Balatro 最核心的中间层:打 Blind / Skip Blind -> 获得奖励 -> 进入商店 -> 购买 / 跳过 / 刷新 -> 构筑变化 -> 进入下一个 Blind / Ante
用流程图表示,大概就是下面这样:
也就是说,第三阶段的方向会调整为:🎯 奖励、商店与构筑成长系统。
原本第三阶段计划中的 "持久化与缓存分层",会延迟到第四阶段再处理。
而本阶段的第一步,我会先从最基础、也最容易忽略的资源开始: 👉 Blind 奖励结算与金币状态设计
二、为什么先从 money 开始
很多游戏项目里,金币看起来只是一个数字。但真正开始做后端时,我发现: money 并不是一个字段,而是一切成长系统的入口。
这一篇的代码改动其实不会特别多。
从实际上看,无非是给 PlayerState 增加 money 字段,再在 Blind 通过后,根据基础奖励、剩余出牌次数、利息进行累加。
但我还是决定把它单独拆出来。
因为从这一篇开始,游戏流程不再只是:
玩家打牌 -> 计算得分 -> 判断是否通过
Blind
而是多了一步:
玩家通过
Blind-> 获得奖励 -> 更新资源状态
这个变化看起来很小,但它是后续商店、Joker、Tag、构筑成长系统的基础。
不过在真正实现之前,还有一个接口层面的取舍需要先想清楚:这次金币奖励,是否需要单独设计一个"提取奖励"的接口?
三、为什么暂时不单独设计"提取奖励"接口
在实际游戏中,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 结算期间 | 用于展示结算后的金币数量 |
这个拆分也让后续扩展更自然。以后如果 Tag、Joker、Voucher 影响金币,也可以继续追加到奖励明细中,而不是往顶层返回结构里继续塞字段。
五、金币奖励规则应该如何配置
确定了金币状态放在哪里之后,接下来要解决的是第二个问题:
金币奖励规则应该放在哪里?
本篇涉及到的金币规则主要有三类:
- 当前
Blind的基础奖励 - 剩余出牌次数奖励
- 利息奖励
虽然它们最后都会影响 playerState.money,但它们的来源并不一样。
5.1 baseRewardMoney 放在 blind.config.ts
首先是当前 blind 的基础奖励,比如:
small blind: 基础奖励 3big blind: 基础奖励 4boss 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 胜利后的奖励结算链路:
这样前端展示结算页面时,就不需要自己反推金币来源了。
6.2 奖励计算发生在 Blind 胜利之后
奖励结算不是每次出牌都会发生,而是只会发生在当前 blind 结束,并且玩家胜利之后。
🎯 RewardMoneyDetail 数据流转关系:
对应代码中,我单独抽出了 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:返回rewardLOSE:只返回失败结算结果
失败不是"获得了 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。
而本篇之后,胜利分支中间多了一步:
这个节点目前只处理金币,但它的意义并不只是 money += reward。
因为从这里开始,玩家状态中开始出现可以被后续系统消费的资源。后面的商店、Joker、Tag、构筑成长,都会建立在这个资源变化之上。
八、总结
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→ 进入下一个阶段
这个变化看起来不大,但它让玩家状态开始具备资源成长能力。
后续商店、购买、刷新、Joker、Tag、Voucher 等系统,都可以继续围绕 money 展开。
8.4 下一步计划
本篇解决的是:
玩家打赢
Blind后,资源如何产生?
下一步要继续解决:
玩家拿到金币后,可以在哪里花?
也就是开始进入商店、购买、刷新与构筑成长相关的设计。