diff --git a/docs_dev/plans/2026-07-23-missing-features-roadmap.md b/docs_dev/plans/2026-07-23-missing-features-roadmap.md index af01c55..45d742b 100644 --- a/docs_dev/plans/2026-07-23-missing-features-roadmap.md +++ b/docs_dev/plans/2026-07-23-missing-features-roadmap.md @@ -97,7 +97,7 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置, - 另:`mana_max` 的「与法杖取 Min」语义(权威 §1.1)未迁入框架,现状是核心值直接覆盖玩家值;那是独立设计判断,记为已知遗留。 - ✅ **`cast_delay_mod` 的饱和区问题已处理(`hard: 0.01 → 0.05`,提交 `ba7c885`,决定已关闭)**:`_handle_auto_cast` 用 `if` 而非 `while`,每物理帧至多一次施法 → 60 次/秒硬顶,实际速率按**整帧量化**,故饱和点 = `(1/60) / 法杖基准间隔`(`circuit_fork` 0.0278 / `wand_basic` 0.0333 / `wand_fast` 0.0667)。原 `hard: 0.01` 远在所有杖的饱和点之下,从饱和点买到 0.01 射速纹丝不动。因饱和点随杖而异、不存在对所有杖都最优的单一 `hard`,取 0.05 为折中:`wand_basic` / `circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)`(较原来的 6.7 倍缩至 1.33 倍)。完整实测数据集见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。若日后要把 `wand_fast` 那段也消掉,正确做法是让 `hard` **逐杖化**(挪进 `cores.json`)而非继续挪这个全局数 —— 属独立立项,**不是本期遗留待办**。 2. ~~**货架 C(属性购买)**~~ ✅ **完成**(2026-07-31,`feat/shelf-c-attribute-shop`;spec/plan `docs_dev/{specs,plans}/2026-07-31-shelf-c-attribute-shop*`):`PriceFormula` 纯静态定价模块(`geometric`/`linear`/`flat` 三曲线,`class_name` 零依赖,可脱离游戏进程单测,风格对齐 `AttributeFormula`);`ShopManager` 新增货架 C 状态(`_attr_purchases` 唯一写入点 `_apply_attr_purchases()` 重建加成,来源常量 `MOD_SOURCE_SHOP_C="shop_c"`);`attributes.json` 逐属性加 `shop` 段(`mode`/`step`/`price_base`/`price_growth`/`curve`)驱动可售集合与定价,**无 `shop` 段即不可售**(数据驱动,加属性零代码,运行时实测:注入无 `shop` 段的合成属性会被正确排除);`ProfileManager` schema 2→3 新增 `attr_purchases` 持久化,`apply_run()` 顺序为**先 `ShopManager.apply_attr_purchases_save` 后 `PlayerStats.load_save_data`**(颠倒会在"已有历史购买"场景下吞血,运行时实测复现:错误顺序丢 30 HP、正确顺序不丢);商店 UI 新增「📊 属性」按钮打开独立子面板(原计划设想内联在商店面板,实测面板仅余 18px 放不下,改独立子面板);设计器「属性」页扩展 `shop` 五字段编辑。 - - ⚠️ **实际范围**:仅落地 `attributes.json` 现有的 4 个框架内属性(`hp_max`/`move_speed`/`cast_delay_mod`/`cpu_limit`)。E3-① 延后的 B 类 7 个属性(`attunement_*` ×4、`luck`、`recharge_speed_mod`、`mana_max` 的 Min 语义)仍待各自前置(暴击链、充能系统、元素伤害管线等)解除后才有意义可买;届时只需给对应属性加 `shop` 段即可上架,**零代码**(`get_sellable_attrs()` 按 `shop` 段存在性筛选,已运行时验证)。 + - ⚠️ **实际范围**:仅落地 `attributes.json` 现有的 4 个框架内属性(`hp_max`/`move_speed`/`cast_delay_mod`/`cpu_limit`)。E3-① 延后的 B 类 7 个属性(`attunement_*` ×4、`luck`、`recharge_speed_mod`、`mana_max` 的 Min 语义)仍待各自前置(暴击链、充能系统、元素伤害管线等)解除后才有意义可买;**「加 `shop` 段即可上架、零代码」只在该属性已接入 PlayerStats 框架之后才成立**——上述 7 个都还没有,届时仍需先在 `scripts/autoloads/player_stats.gd`(裸字段 + `_recompute_attrs()` 一行 + `_attr_effective` 字面量各加一条)与 `addons/game_designer/attribute_tab.gd` 的 `ATTR_ORDER` 常量把它接进框架,之后加 `shop` 段才是零代码(2026-08 最终评审修复:`get_sellable_attrs()` 现同时校验 `shop` 段与 `PlayerStats.has_attr()`,未接线的属性会被排除而非被静默售卖——此前的「已运行时验证」结论范围有误,验证的只是"有 shop 段即出现在列表",没验证"未接线属性会怎样")。另外,商店「属性」子面板当前坐标常量(`scenes/main/combat_s2.gd` 的 `_setup_attr_shop_ui`)在关闭按钮之前最多容纳 6 行,7 个属性一次性全部上架会超出,需要先做布局/滚动改造。 - ✅ **`cast_delay_mod` 的 soft 上限验证**:三个饱和点(`wand_basic` 0.0333 等)全部低于货架购买软上限 `soft`(0.1),常规购买够不到饱和区。运行时实测买到 `soft` 需 22 次购买,此时帧距仍为 3 帧(未饱和),证明 `soft` 才是玩家实际能碰到的约束。 3. **货架 B(核心抽取)**:商店提供 Core 抽取/更换(复用 `_CORE_ROSTER`),与现有换杖打通。S~M。 4. **出售退款 G5**:背包内法术/核心可出售,按 `numerical` 退款比例返还货币。S。 diff --git a/scripts/autoloads/player_stats.gd b/scripts/autoloads/player_stats.gd index b76643f..329bb46 100644 --- a/scripts/autoloads/player_stats.gd +++ b/scripts/autoloads/player_stats.gd @@ -263,3 +263,12 @@ func get_attr_value(attr_id: String) -> float: push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id) return 0.0 return float(_attr_effective[attr_id]) + +## 该属性是否已在框架内实装(即 _recompute_attrs 会为它算出生效值)。 +## 供 ShopManager 等外部读者判定:只有 attributes.json 有 shop 段还不够卖—— +## 若该属性根本没接进 PlayerStats(无裸字段/无 _recompute_attrs 分支/无 _attr_effective 条目), +## 卖出的加成会调用 add_modifier 写入 _modifiers 但永远没有对应的 _compute_attr 分支读取它, +## 玩家花钱买了一个不生效的空气条目。用 _attr_effective(而非 _attr_def)判定,因为 +## _attr_def 只反映 JSON 有没有这一节、不反映代码有没有真的消费它。 +func has_attr(attr_id: String) -> bool: + return _attr_effective.has(attr_id) diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index 7c3456a..e0745ec 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -109,15 +109,44 @@ func _load_attr_definitions() -> void: return _attr_def = parsed -## 可售属性 = 带 shop 段的属性。缺段即不可售(数据驱动,加属性零代码) +## 可售属性 = 带 shop 段「且」已在 PlayerStats 框架内实装的属性。只满足前者不够—— +## 光有 shop 段而 PlayerStats 未接线(无裸字段/_recompute_attrs 分支/_attr_effective 条目) +## 会导致买了空气:扣钱、_attr_purchases 计数增加,但 PlayerStats.get_attr_value 永远读不到 +## 对应的生效值(详见 PlayerStats.has_attr 注释)。数据驱动的「加属性零代码」只在属性已 +## 实装的前提下成立,见 docs_dev/plans/2026-07-23-missing-features-roadmap.md 相应条目订正。 func get_sellable_attrs() -> Array[String]: var out: Array[String] = [] + var unwired: Array[String] = [] for id in _attr_def: - if _attr_def[id].get("shop", null) is Dictionary: - out.append(String(id)) + if not (_attr_def[id].get("shop", null) is Dictionary): + continue + var attr_id: String = String(id) + if PlayerStats.has_attr(attr_id): + out.append(attr_id) + else: + unwired.append(attr_id) # 先收集,诊断挪到本函数返回之后处理,原因见 _report_unwired_shop_attrs out.sort() + if not unwired.is_empty(): + _report_unwired_shop_attrs(unwired) return out +## 诊断故意拆成独立函数、且在 get_sellable_attrs 已经算出 out 之后才调用—— +## 实测 assert(false) 在本项目运行环境下会当场中断「当前函数」的其余执行并返回该函数声明类型 +## 的默认值(此处即空 Array),但不会波及调用方:调用方在函数调用语句之后仍会继续正常执行。 +## 若把 push_error/assert 直接写在 get_sellable_attrs 的收集循环里,一旦命中就会让 +## get_sellable_attrs 本身在此提前中断,返回空数组——不止是排除了那个坏属性,而是连同 +## cpu_limit/move_speed/hp_max/cast_delay_mod 等本来正常的属性也一起从货架上消失, +## 比「静默卖空气」更糟。故诊断必须发生在一次独立的函数调用里,让中断只影响诊断本身。 +func _report_unwired_shop_attrs(unwired: Array[String]) -> void: + for attr_id in unwired: + push_error("ShopManager: 属性「%s」有 shop 段但未接入 PlayerStats 框架,已从可售列表排除" % attr_id) + # 运行中的游戏里 push_error 到不了任何日志通道(已实测,见本项目已知工具坑),故同 + # player_stats.gd:250-257 的既有做法一样补 assert 保证开发期立刻中断可见。本函数只在 + # get_sellable_attrs 检测到不一致时才被调用,而后者只在商店 UI 搭建时调用一次 + #(combat_s2._setup_attr_shop_ui 在 _ready 调用,不在每次刷新的 _refresh_shop_ui 路径上), + # 故这条 assert 不会刷屏。 + assert(false, "ShopManager: shop 段与 PlayerStats 框架不同步:%s" % str(unwired)) + func get_attr_purchases(attr_id: String) -> int: return int(_attr_purchases.get(attr_id, 0))