- 我正在从 0 到 1 实现一个 Balatro 风格的实时游戏后端系统。
- 如果你是第一次看到本系列,可以先阅读第 1 篇了解项目目标、技术路线和最初的牌型判断实现:
- 本系列目前已经推进到第 11 篇,前面已经完成了牌型判断、发牌、出牌 / 弃牌 / 补牌、得分结算、Blind 关卡推进、Boss Blind、Skip Blind、Tag 奖励、金币结算,以及商店阶段的购买闭环。
- 第 10 篇解决的是"商店能不能买东西",本篇继续解决"商店到底应该卖什么"。
- 因此,本篇会把商店商品从固定配置读取,升级为基于 Joker 商品池生成运行时
ShopState,并为后续真实 Joker 效果、Tarot / Planet 等更多商品类型预留扩展空间。
- 该项目的代码已开源,可在 GitHub 与 GitCode 上获取。
- 📌 本文对应代码分支:
origin/feature/shop-item-pool - ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。
- 📌 本文对应代码分支:
✅ 本篇实现了什么
本篇主要完成的是: 商店商品池与运行时商品生成机制。
上一篇商店已经可以进入、购买、刷新,并继续进入下一 Blind;但商品来源仍然比较简单。本篇则继续把商店商品从固定配置读取,升级为基于 Joker 商品池生成运行时 ShopState。
| 模块 | 本篇完成内容 |
|---|---|
| 配置管理 | 将游戏配置收拢到 configs/ 并通过 index.ts 统一导出 |
| Joker 商品池 | 扩充基础 Joker 商品配置,并增加 rarity、enabled 等字段 |
| 商品建模 | 区分静态配置 ShopItemConfig 和运行时实例 ShopItemInstance |
| 商店服务 | 新增 ShopService,负责创建运行时 ShopState |
| 商品生成 | 基于 Joker rarity 权重抽取本次商店商品 |
| 去重与兜底 | 避免同一次商店出现重复 Joker,并在 rarity 池为空时 fallback |
| 流程接入 | 将 ShopService 接入 GameService 的进入商店和刷新商店流程 |
| 单元测试 | 补充 ShopService 测试,覆盖实例生成、价格映射、ID 生成和去重 |
本篇仍然不会实现真实 Joker 计分效果,当前买到的 Joker 仍然只是进入玩家状态。
📌 商店商品生成接入流程图
一、上一篇商店闭环完成后,为什么还要继续改商店?
1. 第 10 篇解决的是"商店能不能跑起来"
上一篇主要完成了 Blind 胜利后的商店闭环:
- 玩家打赢
Blind后,可以进入商店; - 商店里可以展示商品;
- 玩家可以购买
Joker; - 购买后会扣除金币,并把
Joker写入玩家状态; - 玩家也可以刷新商店,然后继续进入下一轮
Blind。
也就是说,第 10 篇主要解决的是:
商店这个流程能不能跑通。
从流程上看,商店已经可以用了。但做到这里之后,我发现商店还有一个问题没有真正解决:
商店里的商品到底是怎么来的?
2. 当前商店还只是"能跑起来"
上一篇的商店更像是一个"能跑起来的商店"。
它可以展示商品,也可以购买商品,但商品来源还比较简单,本质上还是从配置里拿出几个占位 Joker,然后放进 shopState.items。
短期看这没什么问题,因为当时我要验证的是:
- 能不能进入商店
- 能不能展示商品
- 能不能购买商品
- 能不能刷新商店
- 能不能进入下一 Blind
这些都属于商店生命周期的闭环。
但如果后续继续扩展,就会开始暴露新的问题:
reroll后商品为什么会变?- 同一次商店里能不能出现重复
Joker? Joker有没有普通、罕见、稀有之分?- 商店里的商品和原始配置到底是不是同一个东西?
- 玩家购买时,应该传
configId,还是传这次商店生成出来的instanceId? - 后续
Tarot、Planet、Voucher又该怎么接进来?
这些问题已经不是简单再补几个配置就能解决的。
3. 所以这一篇先处理"商品生成"
所以本篇没有继续往 Joker 效果系统走,而是先回头处理商店商品生成机制。
我希望先把商店从:
也就是说,本篇真正推进的不是"多加几个 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
后续如果继续新增 Tarot、Planet、Voucher 配置,也可以自然放进这一层。
这类改动看起来小,但对项目长期维护是有帮助的。
四、扩充 Joker 配置:不是为了复刻,而是为了验证商品池
本篇把原本很少的 Joker 商品配置扩充到了 10 个左右。
这里没有一次性搬完整个 Balatro 的 Joker 列表。
原因很简单:
当前阶段还没有 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 |
静态配置层 | 系统里有哪些商品 | 否 | configId、basePrice、enabled、rarity |
ShopItemInstance |
运行时商店层 | 本次商店生成出来的商品 | 是 | instanceId、price、purchased |
PlayerJoker |
玩家拥有态 | 玩家已经买到的 Joker | 是 | instanceId、configId、name |
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,
};
}
这段流程可以拆成几步:
这张图里最关键的转换点是 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被映射成了priceenabled没有进入运行时实例- 新增了
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 个商品。
如果我每一轮都从完整配置池里随机,就有可能出现:Joker、Joker。这显然不是当前阶段想要的结果。
所以在生成过程中,我维护了一个: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 留扩展点 |
| 不暴露配置层字段 | enabled、basePrice 不进入运行时实例 |
保持配置层和实例层边界 |
| 同一次生成不重复 | 一次商店内不出现重复 configId |
保证当前商店展示结果更合理 |
这些测试保护的是:配置和实例的边界、运行时 ID 的生成规则、商店商品不重复、商品来源于配置池。
它们比直接测试随机概率更稳定。
十一、本篇改动后的商店流程
现在商店相关流程可以理解为下面这样:
十二、为什么本篇没有继续做 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 配置
- 区分
ShopItemConfig和ShopItemInstance - 抽出
ShopService - 基于 rarity 权重生成 Joker 商品
- 同一次商店避免重复商品
- rarity 池为空时 fallback
- 将商品池生成逻辑接入
GameService - 补充
ShopService单元测试
到这里,商店已经不再只是简单展示几个固定商品,而是有了基础的商品池生成机制。
2. 本篇真正的设计重点
这篇真正的重点不是"多加了几个 Joker",也不是"写了一个随机函数"。
我觉得更重要的是这几个边界:
ShopItemConfig:静态配置ShopItemInstance:运行时商品实例GameService:负责游戏流程编排ShopService:负责商店商品生成规则
也就是说,商店商品不再直接等于配置表里的某一项,而是会经过一层生成过程,变成带有 instanceId、price、purchased 等运行时字段的商品实例。
这个拆分让后续扩展折扣、特殊商品、不同来源商品、甚至持久化时,都有了更清楚的落点。
3. 本篇带来的流程变化
如果把上一篇和这一篇放在一起看,商店流程的变化大概是这样:
和上一篇相比,这次最大的变化不是"商店还能不能买东西",而是:
- 商店已经不再只是展示固定商品;
- 商品会先经过
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 系统要解决的是"效果如何执行"。