diff --git a/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md b/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md new file mode 100644 index 0000000..78b7cf0 --- /dev/null +++ b/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md @@ -0,0 +1,266 @@ +# 货架 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`: +```gdscript +## PriceFormula — 价格计算的唯一实现处 +## 纯静态函数:无状态、不依赖 ShopManager / PlayerStats / 场景树,故可脱离游戏进程单元测试 +class_name PriceFormula +extends RefCounted + +enum Curve { 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) -> Curve +``` + +| 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` 段 + +```json +"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"` 完全同构): +```gdscript +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 = + var merged: float = (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()`。存派生源头则完全不涉及这个坑。 + +- 进**局内存档**(`ProfileManager` run_a/run_b),不进 `meta.json` —— 货架 C 是局内成长(§1.3)。 +- `SCHEMA_VERSION` 2 → 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/domain/combat/combat_manager.gd` | 存档读写接入 `_attr_purchases` | +| `scripts/autoloads/profile_manager.gd` | `SCHEMA_VERSION` 2 → 3 | +| `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/2` → `60 / 69 / 79`。 +2. `linear`:`{base:20, growth:10}` + `0/1/2` → `20 / 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_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` 一起确立命名/签名/测试惯例。