Files
spellforge/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md
joywayerandClaude Opus 5 68e3edbc7d docs: 订正 pct 合并公式——必须按 combine 分支,inverse 用 1−(1−step)^n
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>
2026-08-03 11:17:35 +08:00

18 KiB
Raw Permalink Blame History

货架 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),提供 AttributeFormulaPlayerStats.add_modifier / remove_modifiers_from(source)attributes.jsonsoft 字段(E3-① 特意保留、当时无消费方,本期接上)。

本期做:可售属性的数据段、PriceFormula 模块、ShopManager 的货架 C 状态与购买流程、局内存档、商店 UI、设计器扩展、权威文档补录。

本期不做(各有独立理由,见 §1.2):把 B 类「已定义但被阻塞」的 7 个属性变为可售;出售退款(子计划 ④);货架 B 核心抽取(子计划 ③);E7-⑥ 计算模块化重构(已单独立项)。

1. 背景与现状(代码复核 2026-07-31)

1.1 商店现状

shop_manager.gd 仅有货架 Acurrent_slots3 个法术槽)、buy_spellreroll(费用 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_evaluatorMAX_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:81is_crit 硬编码 false),掉率无 luck 输入
attunement_fire/ice/lightning/poison 0 / 50 / 100 每点 +3% 元素伤 + 2% 状态触发率,需接入伤害管线与状态系统

C. PlayerStats 实际字段但不在权威表内 —— 4 个

字段 性质 写入方
mana 运行时状态,非属性 施法消耗 / 每帧回复
mana_regen 事实上的属性,被核心覆盖 set_mana_poolcore.base_mana_regen
mana_leech 事实上的属性,多来源重算 _rebuild_wand ← 核心 + 卡组 meta.mana_leech
mana_heat 运行时状态(递增蓝耗热值) 每次施法 +,每帧衰减

⚠️ mana_regen / mana_leech权威表漏了的真实属性(有基准值、被多来源修正、影响玩法)。而权威给货架 C 举的例子「升级回蓝速度」指的正是 mana_regen —— 它是最可能先补进框架的候选。

D. 资源与进度(不是属性,不上架)hp(当前值)、goldxplevelxp_to_next_invuln_until_msec

E. 法杖侧属性(属于装备,是货架 B 的范畴)CoreDefinitionslot_count / cpu_limit / cast_interval / 拓扑 / feature_tags / base_mana_max+regen+leech

F. 全局配置(影响角色但非角色属性,设计师调不该按局买)balance.jsonplayer_iframe_secdifficulty_mults.player_dmgmana_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.jsonshop

"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 1217 次

cpu_limit 必须 flat —— AttributeFormulaadd_int 明确拒绝 pctpush_error(乘算对离散指令预算无意义)。这条硬约束正是「增量必须逐属性配置」的原因,不是偏好。

首购 60–120G 与最便宜的法术(50G)同档,形成真实竞争(每波收入约 150–250G:击杀 15G + 波次结算 100G + 10% 利息)。

为什么 pctgeometricpct 模式下第 n 次购买的绝对收益base × (1+step)^(n-1) × step,本身几何增长;价格若只线性涨,后期购买会越来越划算,最优解退化为「无脑堆一条轨」,取舍消失。取 price_growth 略大于 1+step 得到温和的边际递减 —— 每次仍值得买,但一直堆同轨会逐渐不划算,自然鼓励铺开。

刷新费用(P6-N23)用线性是对的,因为刷新的价值每次都一样;属性购买的价值在复利,情况不同,不该照搬。

价格递增按「本局该属性累计购买次数」计,不随波次重置(与刷新费用相反)—— 它代表累计投资深度,波次重置会让「每波买一次」变成无脑最优。

2.3 cast_delay_mod 的饱和点够不到 —— soft 才是约束(自审订正)

E3-① 实测:_handle_auto_castif 而非 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: Dictionaryattr_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=1v
    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)

每属性至多一条 _modifiersvalue 为合并值。选此形状而非「每次购买追加一条」的理由:

  • 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 留的警告:_modifiersArray[Dictionary],而 JSON.parse_string / data.get(..., []) 产出无类型 Array,直接 = 赋值是运行时类型错误,必须 .assign()。存派生源头则完全不涉及这个坑。

  • 局内存档ProfileManager run_a/run_b),不进 meta.json —— 货架 C 是局内成长(§1.3)。
  • SCHEMA_VERSION 2 → 3;旧存档无该键 → 视为空字典(无购买),不需要迁移逻辑。
  • 回读顺序:先 _attr_purchases 赋值,再 _apply_attr_purchases(),最后才是依赖 hp_max 的量(E3-① 记录的顺序陷阱:_recompute_attrshp = minf(hp, hp_max) 若拿未加成的 hp_max 去钳会静默吞血)。
  • ShopManager.reset() 清空 _attr_purchasesPlayerStats.reset_for_run() 已清 _modifiers)。

2.6 UI 与设计器

商店面板combat_s2.gd 现有程序化风格,项目无 .tscn UI)新增「属性」区:每个可售属性一行 —— 名称 / 当前生效值 / 下次价格 / 购买按钮。禁用态需说明原因(金币不足 / 已达 soft 上限),而非只是灰掉。

设计器「属性」页扩展 shop 段五字段:mode 下拉(存 key 不存显示串)、step / price_base / price_growth SpinBox、curve 下拉。

⚠️ UI.spinmin 必须为 0.0 —— E3-① 实测:大负数 min 与细 step 相差数量级时,Rangeround((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) 加载)

  1. geometric{base:60, growth:1.15} + purchased 0/1/260 / 69 / 79
  2. linear{base:20, growth:10} + 0/1/220 / 30 / 40(与刷新费用 P6-N23 同构,作交叉验证)。
  3. flat:任意 purchased 恒等于 price_base
  4. purchased = 0 恒返回 price_base(三种曲线皆然)。
  5. 未知 curve 字符串 → push_error 且回退 geometric用唯一标记值 + Debugger 错误树断言报错真的发出)。
  6. 返回类型为 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_limitshop.mode 若误配为 pctadd_modifier 拒绝并 push_erroradd_int 硬约束)。 12. 存档 → 回读:次数与生效值均还原;旧存档(无该键)不崩、视为零购买。 13. reset_for_run / ShopManager.reset() 后次数与加成均清零。 14. 与 "core" 来源共存:换杖不影响货架 C 的加成,反之亦然。

规范 15. 设计器 shop 段回读往返逐位相等(不用容差 —— E3-① 栽过三次);mode/curve 存 key 不存双语显示串。 16. 无 shop 段的属性不上架。 17. validate_script 通过(price_formula.gdclass_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 一起确立命名/签名/测试惯例。