从0到1实现Balatro游戏后端(11):商店商品池与运行时商品实例生成

  • 我正在从 0 到 1 实现一个 Balatro 风格的实时游戏后端系统。
  • 如果你是第一次看到本系列,可以先阅读第 1 篇了解项目目标、技术路线和最初的牌型判断实现:
  • 本系列目前已经推进到第 11 篇,前面已经完成了牌型判断、发牌、出牌 / 弃牌 / 补牌、得分结算、Blind 关卡推进、Boss Blind、Skip Blind、Tag 奖励、金币结算,以及商店阶段的购买闭环。
  • 第 10 篇解决的是"商店能不能买东西",本篇继续解决"商店到底应该卖什么"。
  • 因此,本篇会把商店商品从固定配置读取,升级为基于 Joker 商品池生成运行时 ShopState,并为后续真实 Joker 效果、Tarot / Planet 等更多商品类型预留扩展空间。
  • 该项目的代码已开源,可在 GitHubGitCode 上获取。
    • 📌 本文对应代码分支:origin/feature/shop-item-pool
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。

✅ 本篇实现了什么

本篇主要完成的是: 商店商品池与运行时商品生成机制

上一篇商店已经可以进入、购买、刷新,并继续进入下一 Blind;但商品来源仍然比较简单。本篇则继续把商店商品从固定配置读取,升级为基于 Joker 商品池生成运行时 ShopState

模块 本篇完成内容
配置管理 将游戏配置收拢到 configs/ 并通过 index.ts 统一导出
Joker 商品池 扩充基础 Joker 商品配置,并增加 rarityenabled 等字段
商品建模 区分静态配置 ShopItemConfig 和运行时实例 ShopItemInstance
商店服务 新增 ShopService,负责创建运行时 ShopState
商品生成 基于 Joker rarity 权重抽取本次商店商品
去重与兜底 避免同一次商店出现重复 Joker,并在 rarity 池为空时 fallback
流程接入 ShopService 接入 GameService 的进入商店和刷新商店流程
单元测试 补充 ShopService 测试,覆盖实例生成、价格映射、ID 生成和去重

本篇仍然不会实现真实 Joker 计分效果,当前买到的 Joker 仍然只是进入玩家状态。


📌 商店商品生成接入流程图

flowchart LR S1[阶段一 Blind 结算] A[Blind 胜利] B[奖励结算] C[进入商店阶段] S2[阶段二 商店生成] D[ShopService createShopState] E[ShopItemConfig 静态商品配置] F[按 rarity 权重抽取] G[ShopItemInstance 运行时商品实例] H[shopState.items] S3[阶段三 商店操作] I{玩家操作} J[buyShopItem 通过 instanceId 购买] K[rerollShop 重新生成 ShopState] L[playerState.jokers] S4[阶段四 下一 Blind] M[enterNextRound] N[下一 Blind] S1 --> A --> B --> C C --> S2 --> D --> E --> F --> G --> H H --> S3 --> I I --> J --> L --> S4 --> M --> N I --> K --> D classDef stage fill:aliceblue,stroke:cornflowerblue,stroke-width:2px,color:black classDef service fill:cornsilk,stroke:orange,stroke-width:2px,color:black classDef config fill:lavender,stroke:mediumpurple,stroke-width:2px,color:black classDef state fill:honeydew,stroke:mediumseagreen,stroke-width:2px,color:black classDef action fill:mistyrose,stroke:indianred,stroke-width:2px,color:black classDef decision fill:lightcyan,stroke:lightseagreen,stroke-width:2px,color:black class S1,S2,S3,S4 stage class D service class E,F,G config class C,H,L,N state class J,K,M action class I decision

一、上一篇商店闭环完成后,为什么还要继续改商店?

1. 第 10 篇解决的是"商店能不能跑起来"

上一篇主要完成了 Blind 胜利后的商店闭环:

  • 玩家打赢 Blind 后,可以进入商店;
  • 商店里可以展示商品;
  • 玩家可以购买 Joker
  • 购买后会扣除金币,并把 Joker 写入玩家状态;
  • 玩家也可以刷新商店,然后继续进入下一轮 Blind

也就是说,第 10 篇主要解决的是:

商店这个流程能不能跑通。

从流程上看,商店已经可以用了。但做到这里之后,我发现商店还有一个问题没有真正解决:

商店里的商品到底是怎么来的?

2. 当前商店还只是"能跑起来"

上一篇的商店更像是一个"能跑起来的商店"。

它可以展示商品,也可以购买商品,但商品来源还比较简单,本质上还是从配置里拿出几个占位 Joker,然后放进 shopState.items

短期看这没什么问题,因为当时我要验证的是:

  • 能不能进入商店
  • 能不能展示商品
  • 能不能购买商品
  • 能不能刷新商店
  • 能不能进入下一 Blind

这些都属于商店生命周期的闭环。

但如果后续继续扩展,就会开始暴露新的问题:

  • reroll 后商品为什么会变?
  • 同一次商店里能不能出现重复 Joker
  • Joker 有没有普通、罕见、稀有之分?
  • 商店里的商品和原始配置到底是不是同一个东西?
  • 玩家购买时,应该传 configId,还是传这次商店生成出来的 instanceId
  • 后续 TarotPlanetVoucher 又该怎么接进来?

这些问题已经不是简单再补几个配置就能解决的。

3. 所以这一篇先处理"商品生成"

所以本篇没有继续往 Joker 效果系统走,而是先回头处理商店商品生成机制。

我希望先把商店从:

flowchart LR A[上一篇 固定商品配置] B[简单取几个商品] C[直接放进 shopState] D[本篇 商品配置池] E[按 rarity 权重抽取] F[生成运行时实例] G[组成 ShopState] A --> B --> C C --> D D --> E --> F --> G classDef old fill:mistyrose,stroke:indianred,color:black classDef new fill:honeydew,stroke:mediumseagreen,color:black classDef process fill:cornsilk,stroke:orange,color:black classDef result fill:aliceblue,stroke:cornflowerblue,stroke-width:2px,color:black class A,B,C old class D,E,F process class G result

也就是说,本篇真正推进的不是"多加几个 Joker 配置",而是让商店商品从静态配置,经过一层生成过程,变成本次商店里的运行时商品实例。


二、本篇不是单纯补 Joker 配置

一开始我也有点误解。

我以为所谓"商店商品池",是不是就是把现在的 3 个 Joker 补到 10 个,甚至直接去找 Balatro 原版的 Joker 数据,然后搬进 shop.config.ts

但后来想了一下,如果只是这样,那其实只是配置数量变多了,并没有真正解决商店生成规则的问题。

所以本篇虽然会扩充 Joker 配置,但重点不在于"补了多少张 Joker",而在于:

静态商品配置:可用商品池、本次商店商品、运行时商品实例

也就是说,这篇更关注商品从配置到运行时状态的流转过程。

本篇核心包括:

  • 统一商品配置池
  • 区分静态配置和运行时实例
  • 抽出 ShopService
  • 按 Joker rarity 权重生成商品
  • 同一次商店避免重复商品
  • 生成完整的 ShopState
  • 接入 GameService 的商店流程

三、先整理配置:为什么把游戏配置收拢到 configs?

随着功能越来越多,项目里已经出现了不少配置文件:Blind 配置、Boss Blind 配置、Tag 配置、Economy 配置、Shop 配置。

这些文件虽然在代码里都是 export const,但它们不完全是普通意义上的 constants。

1. constants 和 configs 的区别

一开始我也纠结过:

这些都是 const,为什么不都放进 constants 目录?

但后来我觉得这里需要区分两个概念:constant !== config

比如错误码、事件名、游戏状态,这些更像 constants

它们是程序里的固定值:

ts 复制代码
SUCCESS = 200
GAME_STATUS.PLAYING
game:stateChanged

但像下面这些就更像游戏内容配置:

ts 复制代码
SHOP_ITEM_CONFIG
BOSS_BLIND_CONFIG
TAG_CONFIG
BLIND_SCORE_CONFIG

它们不是简单的常量,而是一张张静态配置表。

比如 SHOP_ITEM_CONFIG 描述的是:

系统里有哪些商店商品。

BOSS_BLIND_CONFIG 描述的是:

系统里有哪些 Boss Blind 规则。

所以这次我没有把它们强行放进 constants/,而是拆出了 configs/ 目录,并通过 index.ts 统一对外导出。

这样外部模块只需要:

ts 复制代码
import { SHOP_ITEM_CONFIG, SHOP_ITEM_TYPE } from "./configs";

而不需要关心具体配置文件在哪里。

2. 这不是功能变化,但让边界更清楚

这一步本身没有改变游戏逻辑。

但它让配置层的边界更清晰:

text 复制代码
configs/
  blind.config.ts
  boss.config.ts
  economy.config.ts
  shop.config.ts
  tag.config.ts
  index.ts

后续如果继续新增 TarotPlanetVoucher 配置,也可以自然放进这一层。

这类改动看起来小,但对项目长期维护是有帮助的。


四、扩充 Joker 配置:不是为了复刻,而是为了验证商品池

本篇把原本很少的 Joker 商品配置扩充到了 10 个左右。

这里没有一次性搬完整个 BalatroJoker 列表。

原因很简单:

当前阶段还没有 Joker 真实效果系统。

如果现在把几十上百个 Joker 都搬进来,配置会变多,但代码结构没有因此变得更清楚。

所以这一篇只补充了一批基础 Joker,用来验证商店商品池机制。

例如:

ts 复制代码
export const SHOP_ITEM_CONFIG = [
  {
    configId: 1001,
    name: "Joker",
    type: SHOP_ITEM_TYPE.JOKER,
    rarity: SHOP_ITEM_RARITY.COMMON,
    basePrice: 5,
    enabled: true,
    description: "+4 Mult. Current stage only stores it, effect will be implemented later.",
    effectType: SHOP_ITEM_EFFECT_TYPE.ADD_TO_JOKER_SLOTS,
  },
  // ...
] as const;

这里新增了几个关键字段:

字段 作用
configId 静态配置 ID,用于标识这个商品配置
type 商品类型,目前只有 Joker
rarity 商品稀有度,用于后续权重抽取
basePrice 配置层基础价格
enabled 是否进入当前商品池
effectType 购买后进入哪个状态,目前只是写入 Joker slots

这里需要注意的是:

本篇仍然不实现 Joker 的真实计分效果。

当前的 Joker 只是可以被购买、可以进入玩家构筑状态。

真正的 Joker effect,会留到后面的 Effect / Modifier 阶段。


五、为什么要区分 ShopItemConfig 和 ShopItemInstance?

这是本篇最重要的设计点之一。

之前商店商品很容易被理解成:配置里有什么,商店里就展示什么

但这其实会混淆两个概念:

  • ShopItemConfig:静态配置
  • ShopItemInstance:本次商店生成出来的运行时商品

1. ShopItemConfig 是静态配置

ShopItemConfig 描述的是:

这个商品本身是什么。

比如:

ts 复制代码
export type ShopItemConfig = {
  configId: number;
  name: string;
  type: ShopItemType;
  rarity: ShopItemRarity;
  basePrice: number;
  enabled: boolean;
  description: string;
  effectType: ShopItemEffectType;
};

这里的 basePrice 是基础价格,enabled 是配置层字段,用来决定这个商品当前是否进入商品池。

这些字段不应该直接变成玩家看到的运行时商品。

2. ShopItemInstance 是运行时实例

当玩家进入商店时,系统会从配置池里抽取几个商品,然后生成本次商店的商品实例:

ts 复制代码
export type ShopItemInstance = {
  instanceId: string;
  configId: number;
  name: string;
  type: ShopItemType;
  rarity: ShopItemRarity;
  price: number;
  description: string;
  effectType: ShopItemEffectType;
  purchased: boolean;
};

这里多了几个运行时字段:

字段 说明
instanceId 本次商店商品实例 ID
price 本次商店实际售价
purchased 当前商品是否已购买

这里我没有直接把 basePrice 暴露给运行时商品,而是映射成了 price

原因是:

basePrice 是配置里的基础价格,而 price 是这一次商店真正展示给玩家的价格。

现在它们相等:price: config.basePrice

但后续如果有折扣、Voucher、Tag、特殊效果,price 就不一定等于 basePrice 了。

这个拆分可以避免后面再重构。

概念 属于哪一层 代表什么 是否会随本次商店变化 典型字段
ShopItemConfig 静态配置层 系统里有哪些商品 configIdbasePriceenabledrarity
ShopItemInstance 运行时商店层 本次商店生成出来的商品 instanceIdpricepurchased
PlayerJoker 玩家拥有态 玩家已经买到的 Joker instanceIdconfigIdname
configId 配置标识 指向商品原型 1001
instanceId 实例标识 指向本次商店里的具体商品 shop_item_player-1_0
purchased 运行时状态 当前实例是否已购买 true / false

所以购买动作真正操作的是 ShopItemInstance,而不是 ShopItemConfig。配置只回答"这个商品是什么",实例才回答"这次商店里的哪个商品被买了"。


六、抽出 ShopService:让 GameService 回到流程编排

在第 10 篇里,商店逻辑主要还是放在 GameService 里。

但做到商品池之后,如果继续把商品生成细节塞进 GameService,会让它越来越臃肿。

因为 GameService 本来就已经负责很多事情:

  • 初始化游戏
  • 发牌
  • 出牌
  • 弃牌
  • Blind 推进
  • Boss Blind 规则
  • Skip Blind
  • Tag
  • 奖励结算
  • 商店购买
  • 进入下一 Blind

如果再让它关心:

  • 从哪个商品池抽
  • 按什么 rarity 抽
  • 怎么避免重复
  • 怎么 fallback
  • 怎么生成 instanceId

GameService 会越来越像一个万能类,所以这一篇抽出了 ShopService

当前分工是:

模块 职责
GameService 游戏流程编排,状态校验,扣金币,写入玩家状态
ShopService 商店状态生成,商品池抽取,运行时商品实例生成

也就是说:

  • GameService: 负责什么时候进入商店
  • ShopService: 负责商店里生成什么

七、ShopService 的核心流程

当前 ShopService 的对外入口是创建商店状态。

它会生成完整的 ShopState

ts 复制代码
    public createShopState(playerId: string, count = SHOP_RULE.SHOP_ITEM_COUNT, cost?: number): ShopState {
        const jokerConfigs = this.getAvailableJokerConfigs();
        const selectedConfigs = this.pickJokerConfigs(jokerConfigs, count);

        const items: ShopItemInstance[] = selectedConfigs.map((config) =>
            this.createShopItemInstance(playerId, config),
        );
        return {
            items,
            rerollCost: SHOP_RULE.DEFAULT_REROLL_COST,
        };
    }

这段流程可以拆成几步:

flowchart LR A[ShopService.createShopState] B[读取可用 Joker 配置] C[按 rarity 权重选择配置] D[生成 ShopItemInstance] E[组成本次 ShopState] F[items] G[rerollCost] A --> B --> C --> D --> E E --> F E --> G classDef service fill:aliceblue,stroke:cornflowerblue,stroke-width:2px,color:black classDef config fill:cornsilk,stroke:orange,color:black classDef instance fill:honeydew,stroke:mediumseagreen,color:black classDef result fill:lavender,stroke:mediumpurple,color:black class A service class B,C config class D instance class E,F,G result

这张图里最关键的转换点是 ShopItemConfig -> ShopItemInstance。前者来自静态配置池,后者才是真正进入 shopState.items 的运行时商品。

1. 获取可用 Joker 配置

当前商店只生成 Joker,所以先从 SHOP_ITEM_CONFIG 里筛选:

ts 复制代码
    private getAvailableJokerConfigs(): ShopItemConfig[] {
        return SHOP_ITEM_CONFIG.filter((item) => item.type === SHOP_ITEM_TYPE.JOKER && item.enabled);
    }

这里先没有做 Tarot / Planet,原因是当前项目还没有实现 Tarot / Planet 的消耗品系统。

如果现在就提前设计完整的:type -> rarity -> item,代码会为了未来还不存在的系统服务,反而增加理解成本。

所以当前阶段先做:rarity -> Joker,后续等 Tarot / Planet 真正进入商店时,再在外面补一层 item type pool。

2. 把配置变成运行时实例

选择出配置后,还需要转换成 ShopItemInstance

ts 复制代码
    private createShopItemInstance(playerId: string, config: ShopItemConfig): ShopItemInstance {
        return {
            instanceId: this.createShopItemInstanceId(playerId),
            configId: config.configId,
            name: config.name,
            type: config.type,
            rarity: config.rarity,
            price: config.basePrice,
            description: config.description,
            effectType: config.effectType,
            purchased: false,
        };
    }

这里可以看到:

  • basePrice 被映射成了 price
  • enabled 没有进入运行时实例
  • 新增了 instanceId
  • 新增了 purchased

这就是静态配置到运行时状态的转换点。


八、基于 rarity 权重生成 Joker 商品

商品池生成的核心,是 pickJokerConfigs

它的目标是:

从可用 Joker 配置中,按稀有度权重生成本次商店商品。

当前配置里有:

ts 复制代码
export const SHOP_JOKER_RARITY_WEIGHT = {
  [SHOP_ITEM_RARITY.COMMON]: 70,
  [SHOP_ITEM_RARITY.UNCOMMON]: 25,
  [SHOP_ITEM_RARITY.RARE]: 5,
} as const;

这里不是每张卡都写一个 weight

原因是我希望表达的是:

common / uncommon / rare 这个 rarity 池子的整体权重。

而不是:

某一张 Joker 自己的抽取权重。

如果把 weight 写在每个 item 上,很容易让 common 卡数量影响整体概率。

比如 common 有 7 张,每张权重 70,那么 common 总权重就会变得非常大。

所以这一版先采用:先抽 rarity、再从该 rarity 池里抽具体 Joker。

1. 抽取 Joker rarity

ts 复制代码
    private pickJokerRarity(): ShopItemRarity {
        const rarityEntries = Object.entries(SHOP_JOKER_RARITY_WEIGHT) as [ShopItemRarity, number][];

        const totalWeight = rarityEntries.reduce((sum, [, weight]) => sum + weight, 0);
        const randomWeight = Math.floor(Math.random() * totalWeight);

        let cumulativeWeight = 0;

        for (const [rarity, weight] of rarityEntries) {
            cumulativeWeight += weight;

            if (randomWeight < cumulativeWeight) {
                return rarity;
            }
        }

        return rarityEntries[rarityEntries.length - 1][0];
    }

这段逻辑的意思是:先算出总权重 -> 随机一个权重值 -> 按累计权重命中某个 rarity

比如:

text 复制代码
common: 70
uncommon: 25
rare: 5

总权重是 100。

随机值落在:

  • 0~69:common
  • 70~94:uncommon
  • 95~99:rare

2. 从对应 rarity 池里抽具体配置

抽到 rarity 后,再去对应池子里拿具体 Joker:

ts 复制代码
    private pickOneConfigByRarity(
        configs: ShopItemConfig[],
        rarity: ShopItemRarity,
        selectedConfigIds: Set<number>,
    ): ShopItemConfig | null {
        const rarityConfigs = configs.filter(
            (config) => config.rarity === rarity && !selectedConfigIds.has(config.configId),
        );

        if (rarityConfigs.length > 0) {
            return this.pickRandomConfig(rarityConfigs);
        }

        const fallbackConfigs = configs.filter((config) => !selectedConfigIds.has(config.configId));

        if (fallbackConfigs.length === 0) {
            return null;
        }

        return this.pickRandomConfig(fallbackConfigs);
    }

这里有两个处理点。

2.1 同一次商店不能重复

商店一次展示 2 个商品。

如果我每一轮都从完整配置池里随机,就有可能出现:JokerJoker。这显然不是当前阶段想要的结果。

所以在生成过程中,我维护了一个:const selectedConfigIds = new Set<number>();

每次选中一个配置后,都把它的 configId 记录下来。

下一次筛选候选池时,会排除已经选中的配置:!selectedConfigIds.has(config.configId)

这样可以保证:

同一次商店展示的商品不重复。

这里我没有限制"整局游戏中不重复出现",因为 reroll 后再次出现同一个 Joker 是可以接受的。

当前只保证本次商店里不重复。

2.2 rarity 池为空时不能直接中断

还有一个边界情况。

假设当前要生成 2 个商品:第一次抽到了 rare,并选中了唯一一张 rare Joker。第二次又抽到了 rare

这时候 rare 池里已经没有未选商品了。

如果直接 break,商店就可能只返回 1 个商品。这不是我想要的。

所以这里做了 fallback:如果抽中的 rarity 没有可用配置,就从所有未选配置里随机补一个。

只有当所有配置都已经不可用时,才返回 null 并停止。

这个处理可以避免因为单个 rarity 池为空导致商店商品数量不足。


九、GameService 如何接入商店生成流程?

抽出 ShopService 后,GameService 不再直接关心商品池生成细节。

在进入商店时,直接生成新的 shopState

ts 复制代码
gameState.shopState = this.shopService.createShopState(playerId, count);

在 reroll 时,也复用同一套生成逻辑:

ts 复制代码
gameState.shopState = this.shopService.createShopState(playerId, count, cost);

这里 GameService 仍然负责:

  • 判断当前是否处于 shopping
  • 判断玩家金币是否足够
  • 扣除金币
  • 更新 gameState.shopState
  • 返回事件和最新状态

ShopService 只负责:

  • 从商品池生成商品
  • 生成 ShopItemInstance
  • 返回 ShopState

也就是说,购买、扣钱、写入玩家 Joker 这些操作仍然留在 GameService

原因是:

购买商品不是纯商店行为,它还会影响玩家金币、玩家构筑和整体游戏状态。

如果把整个 buyShopItem 都搬进 ShopService,那 ShopService 就需要知道太多 GameState 的细节,最后它可能会变成第二个 GameService

所以当前的边界是:

  • GameService:流程编排
  • ShopService:商店规则

十、测试:这次重点不是测随机概率

本篇也补充了 ShopService 的测试。

这里我没有去测试:

text 复制代码
common 一定是 70%
uncommon 一定是 25%
rare 一定是 5%

因为随机概率测试很容易不稳定。

当前测试更关注一些不变量,也就是无论随机到什么,都必须成立的规则。

比如:

1. 能生成指定数量

ts 复制代码
it("should return target count when enough configs are available", () => {
  const state = service.createShopState("player-1", 3);

  expect(state.items).toHaveLength(3);
});

2. 生成运行时 instanceId

ts 复制代码
it("should create runtime instance ids", () => {
  const state = service.createShopState("player-1", 2);

  expect(state.items[0].instanceId).toBe("shop_item_player-1_0");
  expect(state.items[1].instanceId).toBe("shop_item_player-1_1");
});

3. 同一个玩家 ID 递增

ts 复制代码
it("should increase counter for the same player", () => {
  service.createShopState("player-1", 2);
  const nextState = service.createShopState("player-1", 2);

  expect(nextState.items[0].instanceId).toBe("shop_item_player-1_2");
  expect(nextState.items[1].instanceId).toBe("shop_item_player-1_3");
});

汇总来说就是:

测试目标 验证内容 为什么重要
生成指定数量商品 createShopState(playerId, count) 返回指定数量的 items 保证商店商品数量稳定
生成运行时 instanceId 每个商品都有本次商店实例 ID 购买时不能只依赖 configId
同一玩家 ID 计数递增 同一玩家多次生成不会重复实例 ID 避免刷新商店后实例冲突
不同玩家计数隔离 不同玩家的商品实例 ID 独立生成 避免玩家之间状态串扰
basePrice 映射为 price 运行时价格来自配置基础价格 为后续折扣、Voucher、Tag 留扩展点
不暴露配置层字段 enabledbasePrice 不进入运行时实例 保持配置层和实例层边界
同一次生成不重复 一次商店内不出现重复 configId 保证当前商店展示结果更合理

这些测试保护的是:配置和实例的边界、运行时 ID 的生成规则、商店商品不重复、商品来源于配置池。

它们比直接测试随机概率更稳定。


十一、本篇改动后的商店流程

现在商店相关流程可以理解为下面这样:

flowchart LR A[Blind 胜利] B[GameService 进入 shopping] C[ShopService 创建 ShopState] D[shopState.items 保存运行时商品实例] E[玩家通过 instanceId 购买商品] F[GameService 扣金币并写入 playerState.jokers] G[rerollShop 重新生成 ShopState] H[enterNextRound 进入下一 Blind] A --> B --> C --> D --> E --> F D --> G G --> D F --> H classDef flow fill:aliceblue,stroke:cornflowerblue,color:black classDef service fill:cornsilk,stroke:orange,color:black classDef state fill:honeydew,stroke:mediumseagreen,color:black classDef action fill:lavender,stroke:mediumpurple,color:black class A,B flow class C,G service class D,F state class E,H action

十二、为什么本篇没有继续做 Tarot / Planet?

从原版 Balatro 来看,商店里当然不只有 Joker。

后续会有 Tarot、Planet、Spectral、Voucher、Booster Pack 等内容,但我没有在这一篇就把它们全部接进来。

原因是当前项目还没有这些系统的运行时状态。

如果现在提前设计:type -> rarity -> item,或者:Joker / Tarot / Planet / Voucher。代码会变得很"完整",但很多分支其实没有真实业务支撑。

所以这一版先只做 Joker 内部的 rarity pool

后续等 Tarot / Planet 真的有了自己的状态、使用规则和效果系统,再把外层商品类型池补上,会更自然。

这也是这次我想刻意练习的一个点:

  • 不是什么都提前抽象出来才叫设计。
  • 能知道现在该抽到哪里,后面留到哪里,也是一种设计。

十三、本篇小结

1. 当前阶段完成了什么

本篇主要完成了商店商品生成机制的第一版。

从代码结果上看,这次改动并不是特别大;但从设计上看,它解决了一个很关键的问题:

静态配置如何变成本次商店中的运行时商品。

本篇完成的内容包括:

  • 整理 configs/ 配置出口
  • 扩充 Joker 商品配置
  • 增加 Joker rarity 配置
  • 区分 ShopItemConfigShopItemInstance
  • 抽出 ShopService
  • 基于 rarity 权重生成 Joker 商品
  • 同一次商店避免重复商品
  • rarity 池为空时 fallback
  • 将商品池生成逻辑接入 GameService
  • 补充 ShopService 单元测试

到这里,商店已经不再只是简单展示几个固定商品,而是有了基础的商品池生成机制。

2. 本篇真正的设计重点

这篇真正的重点不是"多加了几个 Joker",也不是"写了一个随机函数"。

我觉得更重要的是这几个边界:

  • ShopItemConfig:静态配置
  • ShopItemInstance:运行时商品实例
  • GameService:负责游戏流程编排
  • ShopService:负责商店商品生成规则

也就是说,商店商品不再直接等于配置表里的某一项,而是会经过一层生成过程,变成带有 instanceIdpricepurchased 等运行时字段的商品实例。

这个拆分让后续扩展折扣、特殊商品、不同来源商品、甚至持久化时,都有了更清楚的落点。

3. 本篇带来的流程变化

如果把上一篇和这一篇放在一起看,商店流程的变化大概是这样:

flowchart LR P0[上一篇] P1[Blind 胜利] P2[进入商店] P3[展示固定商品] P4[购买或刷新] P5[进入下一 Blind] C0[对比变化] N0[本篇之后] N1[Blind 胜利] N2[GameService 进入 shopping] N3[ShopService 从商品池创建 ShopState] N4[shopState.items 保存运行时商品实例] N5[玩家通过 instanceId 购买商品] N6[GameService 扣金币并写入 playerState.jokers] N7[rerollShop 重新生成 ShopState] N8[enterNextRound 进入下一 Blind] P0 --> P1 --> P2 --> P3 --> P4 --> P5 N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N8 N4 --> N7 --> N4 C0 -.-> P0 C0 -.-> N0 classDef oldRoot fill:mistyrose,stroke:indianred,stroke-width:2px,color:black classDef oldStep fill:cornsilk,stroke:orange,color:black classDef newRoot fill:aliceblue,stroke:cornflowerblue,stroke-width:2px,color:black classDef newStep fill:honeydew,stroke:mediumseagreen,color:black classDef service fill:lavender,stroke:mediumpurple,color:black classDef compare fill:white,stroke:gray,stroke-width:2px,color:black class P0 oldRoot class P1,P2,P3,P4,P5 oldStep class N0 newRoot class N1,N2,N4,N5,N6,N7,N8 newStep class N3 service class C0 compare

和上一篇相比,这次最大的变化不是"商店还能不能买东西",而是:

  • 商店已经不再只是展示固定商品;
  • 商品会先经过 ShopService 从商品池生成 ShopState
  • 玩家购买的对象也从"固定展示商品"变成了"本次商店里的运行时商品实例"。

所以这次改动真正补上的,是:

商店不仅能卖东西了,而且开始知道"这次该卖什么"了。

4. 当前还没有实现什么

本篇仍然没有实现真实 Joker 效果。

当前买到的 Joker 只是进入玩家状态,暂时不会真正影响出牌得分。

同时,本篇也没有继续扩展 Tarot、Planet、Spectral、Voucher、Booster Pack 等商品类型。

原因是当前项目还没有这些系统的运行时状态和使用逻辑。现在如果提前把完整的 type -> rarity -> item 商品池都做出来,会让代码为还不存在的系统服务,反而增加复杂度。

所以这一篇先停在 Joker 内部的 rarity pool。

5. 下一步计划

下一阶段开始,问题会从"商店卖什么"继续往后推进到:

玩家买到 Joker 后,它到底怎么生效?

也就是需要继续思考:

  • Joker 效果应该在哪个时机触发?
  • 它是直接修改分数,还是通过统一的 effect pipeline 执行?
  • Joker、Tag、Boss Blind 这些效果之间要怎么组合?
  • 临时效果、常驻效果、一次性效果应该如何区分生命周期?

这也是后面系统会明显变复杂的地方。

前面的商店商品池解决的是"商品如何生成",而接下来的 Effect / Modifier 系统要解决的是"效果如何执行"。

相关推荐
AskHarries2 小时前
OAuth 登录怎么接
后端
掘金者阿豪3 小时前
上线前能跑,上线后挂了,三个隐性的SQL陷阱
后端
用户8356290780513 小时前
如何使用 Python 为 PDF 添加和管理图层
后端·python
SimonKing3 小时前
那个号称"对标 Spring"的国产框架 Solon,到底行不行?
java·后端·程序员
霸道流氓气质3 小时前
SpringBoot中使用字典驱动的动态路由示例
java·spring boot·后端
用户69371750013843 小时前
Kimi K3 综合能力处于全球第一梯队
android·前端·后端
anno4 小时前
Agent 新手 Skill 优先级指南:从第一套必装工作流,到专业能力补全
前端·后端
anno4 小时前
别再把文档“一刀切”:用 LangChain.js 做好 RAG 的第一公里
前端·后端
用户713874229004 小时前
claude_rules 深度解析:Claude Code 的模块化路径作用域规则
后端