spec §2.4 与计划 Task2 写的 merged = (1+step)^n − 1 只对 hybrid 成立 (AttributeFormula 对 hybrid 用 f=1+v)。inverse 用的是 f=1−v,于是 cast_delay_mod 实际算成 f = 2−1.1^n 而非意图中的 0.9^n: n=2 应 0.81 实得 0.79;n=7 应 0.478 实得 0.0513 后果是该属性在第 7 次购买就撞 soft 封顶(设计意图约 22 次), 可购买次数砍到 1/3、边际收益曲线整体错误,且零诊断。 评审真机连续购买 7 次坐实。此为 spec 源头错误,非实现偏差。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
货架 C(属性购买)设计
日期:2026-07-31 Epic:E3 经济与 build · 子计划 ②(缺失功能路线图
docs_dev/plans/2026-07-23-missing-features-roadmap.md) 优先级:P0 · 工作量 S~M 目标:商店可购买属性提升,消耗金币,经 E3-① 的加成层叠加到PlayerStats;新增PriceFormula定价模块。
0. 范围与前置
依赖:E3-①「属性词条系统」已完成(master tip 9361bf8),提供 AttributeFormula、PlayerStats.add_modifier / remove_modifiers_from(source)、attributes.json 的 soft 字段(E3-① 特意保留、当时无消费方,本期接上)。
本期做:可售属性的数据段、PriceFormula 模块、ShopManager 的货架 C 状态与购买流程、局内存档、商店 UI、设计器扩展、权威文档补录。
本期不做(各有独立理由,见 §1.2):把 B 类「已定义但被阻塞」的 7 个属性变为可售;出售退款(子计划 ④);货架 B 核心抽取(子计划 ③);E7-⑥ 计算模块化重构(已单独立项)。
1. 背景与现状(代码复核 2026-07-31)
1.1 商店现状
shop_manager.gd 仅有货架 A:current_slots(3 个法术槽)、buy_spell、reroll(费用 20 + n×10,P6-N23,波次结算后重置)。grep 确认无 shelf/sell/core/attr 任何字段。商店 UI 全程序化,在 combat_s2.gd:281 _setup_shop_ui()(3 个槽位按钮 + 刷新 + 法杖 + 背包)。
1.2 角色属性全貌盘点
货架 C 的可售集合会持续扩展,故先盘清全貌。只有 A 类今天可卖。
A. 已在属性框架内 —— 4 个(本期可售)
| id | base | soft | hard | combine | 消费点 |
|---|---|---|---|---|---|
cpu_limit |
0 | 20 | 50 | add_int |
spell_evaluator 的 MAX_OPS = 值 × 40 |
move_speed |
200 | 600 | 800 | hybrid |
player_manager 每物理帧 |
cast_delay_mod |
1.0 | 0.1 | 0.05 | inverse |
_handle_auto_cast 开火时 |
hp_max |
100 | 2000 | 无 | hybrid |
治疗钳制 / 血量百分比 / 卖血上限 |
B. 权威表已定义、未进框架 —— 7 个(各有明确前置,本期不做)
| id | 权威 base/soft/hard | 卡在哪 |
|---|---|---|
mana_max |
100 / 1000 / — | 现状由核心 base_mana_max 直接覆盖;权威 §1.1 说「与法杖取 Min」,该语义未决(E3-① 记为遗留) |
recharge_speed_mod |
1.0 / 5.0 / — | 充能系统不存在(全项目 grep recharge 零命中) |
luck |
0 / 100 / — | 权威说它影响「高阶法术掉率 + 暴击率」,两条都没接:暴击链是死的(bullet_manager.gd:81 把 is_crit 硬编码 false),掉率无 luck 输入 |
attunement_fire/ice/lightning/poison |
0 / 50 / 100 | 每点 +3% 元素伤 + 2% 状态触发率,需接入伤害管线与状态系统 |
C. PlayerStats 实际字段但不在权威表内 —— 4 个
| 字段 | 性质 | 写入方 |
|---|---|---|
mana |
运行时状态,非属性 | 施法消耗 / 每帧回复 |
mana_regen |
事实上的属性,被核心覆盖 | set_mana_pool ← core.base_mana_regen |
mana_leech |
事实上的属性,多来源重算 | _rebuild_wand ← 核心 + 卡组 meta.mana_leech |
mana_heat |
运行时状态(递增蓝耗热值) | 每次施法 +,每帧衰减 |
⚠️
mana_regen/mana_leech是权威表漏了的真实属性(有基准值、被多来源修正、影响玩法)。而权威给货架 C 举的例子「升级回蓝速度」指的正是mana_regen—— 它是最可能先补进框架的候选。
D. 资源与进度(不是属性,不上架):hp(当前值)、gold、xp、level、xp_to_next、_invuln_until_msec。
E. 法杖侧属性(属于装备,是货架 B 的范畴):CoreDefinition 的 slot_count / cpu_limit / cast_interval / 拓扑 / feature_tags / base_mana_max+regen+leech。
F. 全局配置(影响角色但非角色属性,设计师调不该按局买):balance.json 的 player_iframe_sec、difficulty_mults.player_dmg、mana_heat{...}、infinite_spells{...}。
1.3 权威文档说了什么
docs/design/game_design.md §5:货架 C = 「基础属性提升(生命值、暴击率)」,位于局内循环的「购物」环节,与货架 A/B 并列。经济策略原文:
是买一个强力的"黑洞"法术?还是把钱用来升级"回蓝速度"以支撑现有的构建?
结论:局内成长、消耗金币、与法术购买抢同一笔钱。 路线图写的「金币/以太」是含糊 —— 以太(MetaProgress.aether)存在 user://meta.json、跨局持久、用于永久解锁法术,与货架 C 语义不符。
这也意味着 E3-① 的 reset_for_run() 清空 _modifiers 是正确的,无需改动。
权威举的三个例子中,「生命值」可用,「暴击率」被暴击链阻塞,「回蓝速度」(
mana_regen) 不在框架内。举例是示意不是规格 —— 本期按「框架内现成可卖的」取集合。
1.4 定价无权威(本设计需补录)
numerical_design.md §1.2 经济系统的消耗清单只有三项:购买法术 (50-500G)、购买法杖 (200-2000G)、刷新商店 (20G)。属性购买缺席;§1.1 也只给 base/soft/hard,无成长曲线。
故本期定价属设计而非查表,且定稿后必须补回 §1.2(见 §2.7)。
与归航(E1-③)那次的对照:那次是有权威而没查(
numerical_design的 Mana 列与每个已实现法术吻合,证明它是活的),属真错误。本次是查了、确认没有。「以权威为准」的前提是先确认权威存在。
2. 设计
2.1 PriceFormula —— 定价的唯一实现处
架构原则(用户 2026-07-31 指定):凡涉及计算的都由专门功能模块负责,便于测试与配置。AttributeFormula 是已验证的模板。
scripts/domain/price_formula.gd:
## PriceFormula — 价格计算的唯一实现处
## 纯静态函数:无状态、不依赖 ShopManager / PlayerStats / 场景树,故可脱离游戏进程单元测试
class_name PriceFormula
extends RefCounted
enum PriceCurve { GEOMETRIC, LINEAR, FLAT }
## 唯一入口。spec 来自数据文件;本模块**不认识**「属性/法杖/装备」,只认识 {price_base, price_growth, curve}
## purchased —— 已购次数(0 = 首次购买)
static func compute(spec: Dictionary, purchased: int) -> int
## curve 字符串 → 枚举;未知值 push_error 并回退 GEOMETRIC
static func curve_from_string(s: String) -> PriceCurve
| curve | 公式 |
|---|---|
geometric(默认) |
price_base × price_growth^n |
linear |
price_base + n × price_growth |
flat |
price_base |
返回 int(金币是整数),内部 roundi;下限 1(免费购买无意义)。
模块对「卖什么」一无所知,这是货架 B(核心抽取)与子计划 ④(出售退款)能直接复用它的前提。「按不同属性、武器、装备细致划分」的划分点在数据里,不在模块里。
2.2 attributes.json 的 shop 段
"hp_max": {
"display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid",
"shop": { "mode": "pct", "step": 0.10, "price_base": 60, "price_growth": 1.15, "curve": "geometric" }
}
shop 段缺失 = 不可售。 这自动覆盖 §1.2 B 类那 7 个被阻塞的属性 —— 它们将来进框架时不写 shop 段即不上架,前置解决后加一段即可,零代码。
本期初值(设计器可调,真正的平衡交给试玩):
| 属性 | mode | step | price_base | price_growth | 到 soft 约需 |
|---|---|---|---|---|---|
hp_max |
pct |
0.10 | 60 | 1.15 | 31 次 |
move_speed |
pct |
0.08 | 70 | 1.15 | 14 次 |
cast_delay_mod |
pct |
0.10 | 90 | 1.20 | 22 次 |
cpu_limit |
flat |
1 | 120 | 1.30 | 12–17 次 |
cpu_limit 必须 flat —— AttributeFormula 的 add_int 明确拒绝 pct 并 push_error(乘算对离散指令预算无意义)。这条硬约束正是「增量必须逐属性配置」的原因,不是偏好。
首购 60–120G 与最便宜的法术(50G)同档,形成真实竞争(每波收入约 150–250G:击杀 1–5G + 波次结算 100G + 10% 利息)。
为什么 pct 配 geometric:pct 模式下第 n 次购买的绝对收益是 base × (1+step)^(n-1) × step,本身几何增长;价格若只线性涨,后期购买会越来越划算,最优解退化为「无脑堆一条轨」,取舍消失。取 price_growth 略大于 1+step 得到温和的边际递减 —— 每次仍值得买,但一直堆同轨会逐渐不划算,自然鼓励铺开。
刷新费用(P6-N23)用线性是对的,因为刷新的价值每次都一样;属性购买的价值在复利,情况不同,不该照搬。
价格递增按「本局该属性累计购买次数」计,不随波次重置(与刷新费用相反)—— 它代表累计投资深度,波次重置会让「每波买一次」变成无脑最优。
2.3 cast_delay_mod 的饱和点够不到 —— soft 才是约束(自审订正)
E3-① 实测:_handle_auto_cast 用 if 而非 while,每物理帧至多施法一次,故存在饱和点 = (1/60) / 法杖基准间隔 —— wand_basic 0.0333、wand_fast 0.0667、circuit_fork 0.0278。
但正常购买够不到任何一个饱和点。 cast_delay_mod 越低越快,购买从 1.0 往下走,而 soft(0.1) 先把你拦住;三个饱和点(0.0333 / 0.0667 / 0.0278)全部低于 0.1:
| 法杖 | 基准间隔 | soft(0.1) 时的实际间隔 |
帧距 | 速率 | 饱和? |
|---|---|---|---|---|---|
wand_basic |
0.5 s | 0.050 s | 3 帧 | 20 /s | 否(饱和需 ≤ 0.0333) |
wand_fast |
0.25 s | 0.025 s | 2 帧 | 30 /s | 否(饱和需 ≤ 0.0667) |
circuit_fork |
0.6 s | 0.060 s | 4 帧 | 15 /s | 否(饱和需 ≤ 0.0278) |
故货架 C 的 UI 只需 soft 一道闸,不需要动态的逐杖饱和判定。
📌 自审订正记录:本节初稿写「饱和点高于
soft,买到soft之前就已饱和,UI 必须做逐杖动态判定」—— 方向搞反了。inverse属性的「高/低」在语义上易反:数值更低 = 更快 = 更好,故「饱和点 0.0333 低于 soft 0.1」意味着先撞 soft,而非先饱和。差点规定了一个永远触发不了的 UI 功能。教训与本项目已记录的三次「断言宽到抓不住」同源:inverse语义下任何「高于/低于」的论断都必须代入具体数字验算一次。
饱和点只在将来出现能突破 soft 的特殊来源(传奇核心 / 元进展)时才成为约束,届时才需要那套判定。完整实测数据见 docs_dev/specs/2026-07-31-player-attributes-design.md §2.2b。
2.4 状态与唯一写入点
ShopManager 新增 _attr_purchases: Dictionary(attr_id: String → 次数: int)。
购买流程:
校验 shop 段存在 → 校验金币足够 → 校验未达 soft / 未饱和
→ gold -= PriceFormula.compute(spec, n)
→ _attr_purchases[id] = n + 1
→ _apply_attr_purchases()
_apply_attr_purchases() 重建加成(与 _rebuild_wand 处理 "core" 完全同构):
PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
for id in _attr_purchases:
var n: int = _attr_purchases[id]
if n <= 0: continue
var sp: Dictionary = <attributes.json[id].shop>
# pct 合并须按 combine 分支:AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1−v
var merged: float = (1.0 - pow(1.0 - step, n)) if (mode == "pct" and combine == "inverse") else ((pow(1.0 + step, n) - 1.0) if mode == "pct" else (step * n))
PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C)
每属性至多一条 _modifiers,value 为合并值。选此形状而非「每次购买追加一条」的理由:
pct模式下 3 条+10%连乘为 ×1.331,没有等价的单一pct值(除非写 0.331);合并时直接算(1+step)^n - 1,语义明确,不必要求公式模块对同源多条做特殊处理。- 出售退款(子计划 ④)只需次数减一后重算,不需要给
PlayerStats新增按条撤销的 API。
唯一写入点:所有改动 _attr_purchases 的路径(购买、出售退款、存档回读、reset)都必须经 _apply_attr_purchases()。_attr_purchases 与 _modifiers 不同步会产生「显示买了 3 次但加成只有 2 次」,且无任何诊断 —— 收敛写入点是唯一防线。
新增常量 ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"(理由同 PlayerStats.MOD_SOURCE_CORE:来源串在 remove/add 两侧必须逐字相同,打错即静默累积)。
2.5 持久化
只存 _attr_purchases,不存 _modifiers —— 后者是前者的派生物,回读时经 _apply_attr_purchases() 重建。
这躲开了 E3-① 在 player_stats.gd 留的警告:_modifiers 是 Array[Dictionary],而 JSON.parse_string / data.get(..., []) 产出无类型 Array,直接 = 赋值是运行时类型错误,必须 .assign()。存派生源头则完全不涉及这个坑。
- 进局内存档(
ProfileManagerrun_a/run_b),不进meta.json—— 货架 C 是局内成长(§1.3)。 SCHEMA_VERSION2 → 3;旧存档无该键 → 视为空字典(无购买),不需要迁移逻辑。- 回读顺序:先
_attr_purchases赋值,再_apply_attr_purchases(),最后才是依赖hp_max的量(E3-① 记录的顺序陷阱:_recompute_attrs的hp = minf(hp, hp_max)若拿未加成的hp_max去钳会静默吞血)。 ShopManager.reset()清空_attr_purchases(PlayerStats.reset_for_run()已清_modifiers)。
2.6 UI 与设计器
商店面板(combat_s2.gd 现有程序化风格,项目无 .tscn UI)新增「属性」区:每个可售属性一行 —— 名称 / 当前生效值 / 下次价格 / 购买按钮。禁用态需说明原因(金币不足 / 已达 soft 上限),而非只是灰掉。
设计器「属性」页扩展 shop 段五字段:mode 下拉(存 key 不存显示串)、step / price_base / price_growth SpinBox、curve 下拉。
⚠️ UI.spin 的 min 必须为 0.0 —— E3-① 实测:大负数 min 与细 step 相差数量级时,Range 的 round((v-min)/step)*step+min 产生抵消误差(0.01 → 0.00999999999476),会把噪声写进权威数据文件,且容差断言察觉不到。
2.7 权威文档补录
定稿后把属性购买补进 numerical_design.md §1.2 的消耗清单(现仅法术/法杖/刷新三项):各属性的首购价与成长曲线、「按累计购买次数递增、不随波次重置」的规则、以及 cast_delay_mod 的饱和点上限。
这是本设计的组成部分而非可选项 —— §1.4 查明该清单缺失属性购买,补上它才闭环。
3. 影响文件
| 文件 | 改动 |
|---|---|
scripts/domain/price_formula.gd |
新增 —— 定价模块(+ .gd.uid) |
data/attributes.json |
四个属性各加 shop 段 |
scripts/autoloads/shop_manager.gd |
_attr_purchases + MOD_SOURCE_SHOP_C + 购买/校验/_apply_attr_purchases + reset |
scripts/autoloads/profile_manager.gd |
SCHEMA_VERSION 2 → 3;_collect_run_data 写入、apply_run 回读(顺序必须早于 load_save_data,见 §2.5) |
scenes/main/combat_s2.gd |
商店「属性」区 UI |
addons/game_designer/attribute_tab.gd |
shop 段五字段 |
docs/design/numerical_design.md |
§1.2 消耗清单补属性购买 |
4. 验收标准
纯函数(不启动游戏,ResourceLoader.load(..., CACHE_MODE_IGNORE) 加载):
geometric:{base:60, growth:1.15}+purchased 0/1/2→60 / 69 / 79。linear:{base:20, growth:10}+0/1/2→20 / 30 / 40(与刷新费用 P6-N23 同构,作交叉验证)。flat:任意purchased恒等于price_base。purchased = 0恒返回price_base(三种曲线皆然)。- 未知
curve字符串 →push_error且回退geometric(用唯一标记值 + Debugger 错误树断言报错真的发出)。 - 返回类型为
int,且下限 1。
运行时:
7. 买 3 次 hp_max → 生效 hp_max = 100 × 1.1³ = 133.1(验证连乘而非线性的 130)。
8. 金币按几何序列扣除(60 → 69 → 79),且 _attr_purchases 与生效值一致。
9. 达 soft 后按钮禁用且 buy API 拒绝。
10. cast_delay_mod 买到 soft(0.1) 即禁用;并断言此时尚未饱和(wand_basic 实测 3 帧/次 = 20 /s,非 1 帧)—— 证明 §2.3 的订正成立、无需逐杖饱和判定。
11. cpu_limit 的 shop.mode 若误配为 pct → add_modifier 拒绝并 push_error(add_int 硬约束)。
12. 存档 → 回读:次数与生效值均还原;旧存档(无该键)不崩、视为零购买。
13. reset_for_run / ShopManager.reset() 后次数与加成均清零。
14. 与 "core" 来源共存:换杖不影响货架 C 的加成,反之亦然。
规范:
15. 设计器 shop 段回读往返逐位相等(不用容差 —— E3-① 栽过三次);mode/curve 存 key 不存双语显示串。
16. 无 shop 段的属性不上架。
17. validate_script 通过(price_formula.gd 有 class_name,须用 CACHE_MODE_IGNORE 验证,validate_script 对其必然假阴性)。
5. 非目标(YAGNI)
- B 类 7 个属性变为可售 —— 各有前置(§1.2),前置解决后加
shop段即可,零代码。 mana_regen/mana_leech迁入属性框架 —— 它们是权威表漏掉的真实属性、也是最可能的下一批候选,但迁移是独立工作(mana_max还牵连「与法杖取 Min」的未决语义)。- 出售退款 G5 —— 子计划 ④。本期的
_attr_purchases形状已为其铺好(次数减一后重算即可)。 - 货架 B 核心抽取 —— 子计划 ③。
PriceFormula已设计为可直接复用。 - 随机属性词条 / 稀有大幅提升 —— 固定轨先行,将来觉得缺惊喜再议。
- 以太(跨局)购买属性 —— 语义不符(§1.3)。
- E7-⑥ 计算模块化重构 —— 已独立立项(路线图 E7-⑥)。
PriceFormula会成为第二个已模块化的例子,与AttributeFormula一起确立命名/签名/测试惯例。