Compare commits

...
35 Commits
Author SHA1 Message Date
joywayer db2f8e76b4 merge(shop): 货架 C —— 属性购买(E3-②)
新增 PriceFormula 纯静态定价模块(geometric/linear/flat 三曲线,class_name
零依赖,可脱离游戏进程单测),与 AttributeFormula 一起确立「计算归专门模块」
的第二个范例(E7-⑥ 计算模块化重构的先例)。

attributes.json 逐属性加 shop 段驱动可售集合与定价——但「零代码上架」只在该
属性已接入 PlayerStats 框架之后成立:get_sellable_attrs / can_buy_attribute
双侧校验 has_attr,未接线的属性被排除而非被静默售卖。

ShopManager 以 _apply_attr_purchases() 为唯一写入点重建加成(只存购买次数,
加成是派生物);ProfileManager schema 2→3 持久化次数,apply_run 顺序为先回读
购买后 load_save_data;start_game 接线 ShopManager.reset() 防跨局残留。
商店 UI 新增「📊 属性」独立子面板,设计器「属性」页扩展 shop 五字段。
2026-08-03 14:37:47 +08:00
joywayer 06f0049221 fix(shop): can_buy_attribute 同样校验属性已接线;订正路线图残留表述
购买路径是独立守门人:货架 B / 出售退款等后来者会直接调 can_buy_attribute
而不必先过 get_sellable_attrs,只在展示侧拦截不足以保住不变量。未接线属性
生效值恒为 0,_at_soft_cap 因此永不封顶,可被无限买成空气。

同时把路线图第 99 行残留的「加属性零代码」括注收敛到下一条 ⚠️ 订正,避免
同一条目里两句话互相矛盾。
2026-08-03 13:02:20 +08:00
joywayer ce4ce2b392 docs(shop): 记录属性子面板行容量上限,供后续扩容前置说明
商店「属性」子面板行坐标 y = 220 + i*34,关闭按钮占 y=[420,460];按当前坐标常量
算出 250+34i <= 420 ⟺ i <= 5,即最多 6 行(i=0..5)不与关闭按钮重叠,第 7 行
(i=6)落在 y=424 正压在按钮上。路线图 E3-① 延后的 7 个属性一旦全部上架会超出
此容量。本轮不实现滚动/布局改造(超出本次合并范围),仅加注释记录数字,供后续
实现者在动那批属性之前先规划布局。
2026-08-03 12:51:30 +08:00
joywayer 8680e8c0ef fix(designer): 属性页仅为已有 shop 段的属性写回该段
_collect() 此前无条件为 ATTR_ORDER 里每个属性写出 entry["shop"](因为 _shop_rows
在 _ready() 里对全部 4 个属性都无条件建了控件行,哪怕原属性没有 shop 段也会以 0 值
占位)。若某属性本来没有 shop 段,保存会写出 price_base=0 的空段,PriceFormula 会把
0 价地板钳到 1G,等于把一个零效果属性变成 1G 无限购买,直接反转货架 C「无 shop 段
即不可售」的核心不变量。

加一道 had_shop 判定:只有 _data 里原本就带 shop 段的属性才写回该段;已有段的属性
继续沿用原有的 duplicate(true) 合并式防丢键行为不变。当前 4 个 ATTR_ORDER 属性都带
shop 段,故此路径今天不可达,需设计器保存才会触发;已用手工构造的 _data/_rows/_shop_rows
调用 _collect() 验证:摘掉 shop 段的属性不再被写回该段,其余属性不受影响。
2026-08-03 12:51:23 +08:00
joywayer 5889b3c1e5 fix(shop): 可售属性排除未接线项,修正路线图「零代码」表述
get_sellable_attrs() 此前只看 attributes.json 是否有 shop 段,不看 PlayerStats
是否真的实现了该属性。运行时实测:给一个合成属性加 shop 段(不改任何代码)会被列出、
可买、扣钱、购买计数增加,但 get_attr_value 永远读到 0.00——玩家买到的是空气,
且 _at_soft_cap 因为 0.0 < soft 永远不封顶,变相无限花钱买无效果。

PlayerStats 新增 has_attr(attr_id) 谓词(查 _attr_effective,只有 _recompute_attrs
真正算过的属性才算数,不是查 _attr_def 有没有这一节);get_sellable_attrs 用它做
第二道过滤。诊断特意拆进独立函数 _report_unwired_shop_attrs 才调用 push_error+assert——
实测 assert(false) 在本项目运行环境下会让「当前函数」提前返回声明类型的默认值:若诊断写
在收集循环里,一旦命中就会让 get_sellable_attrs 本身连同已收集好的合法属性一起返回空数组,
比原来的「静默卖空气」更糟(整个货架消失);拆成独立函数调用后,中断只发生在诊断函数
自己的调用帧内,调用方(get_sellable_attrs)仍会正常继续并返回过滤后的正确列表。
已注入未接线属性验证:4 个合法属性照常出现,注入项被排除,无一致性问题。

顺带订正路线图 docs_dev/plans/2026-07-23-missing-features-roadmap.md 里「加 shop 段
即可上架、零代码」的表述——该结论只在属性已先接入 PlayerStats 框架(player_stats.gd
的裸字段 + _recompute_attrs 分支 + _attr_effective 条目,以及 attribute_tab.gd 的
ATTR_ORDER)之后才成立,E3-① 延后的 7 个属性都还没有这一步;同时记录商店属性子面板
当前坐标最多容纳 6 行的限制,供后续实现者提前规划。
2026-08-03 12:51:13 +08:00
joywayer 56d7eefdc8 fix(shop): 新局启动接线 ShopManager.reset(),修复购买记录跨局残留
ShopManager.reset() 此前从未被任何地方调用(grep 零命中):PlayerStats.reset_for_run()
只清 _modifiers,_attr_purchases 不清,两份状态跨局错开——新局价格从已购价起算、
UI 显示「已购 N 次」却生效值是基准值,且买任意一件属性会把整份旧 _attr_purchases
重新套用回 PlayerStats(复现:hp_max 购买 2 次后重开局,加成消失但计数还在;
再买一次 hp_max 后旧的两次加成随之复活)。在 start_game() 内 PlayerStats.reset_for_run()
之后调用 ShopManager.reset()——顺序不能反,reset() 会撤销 shop_c 加成,须晚于
reset_for_run() 清空 _modifiers 之后才不会把两处清理搅乱。
2026-08-03 12:50:56 +08:00
joywayerandClaude Opus 5 7c8c1ec564 docs(plan): 订正 Task 6 两处评审指出的归因表述(不改代码/数值)
Step 6 归因写反:脚本手工内联复现两种顺序,从未调用 ProfileManager.apply_run(),
故对调 apply_run() 内部顺序不影响其结果;真正的 apply_run() 顺序回归哨兵是 Step 5b
(评审对调 profile_manager.gd:68-69 顺序后,Step 5b 未改一字即 FAILS=1)。

「22」出处指代错误:task-6-brief.md 全文无此数字,实际出自
docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md:133 设计表,数值本身无误。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:30:58 +08:00
joywayerandClaude Opus 5 0b631a475f docs(shop): 货架 C Task 6 运行时验收 + 权威文档补录 + 计划回填
17 条验收标准 + Task 3 遗留的回读顺序风险全部运行时实测通过(FAILS=0),
无代码改动。发现简报三处脚本不可用并替换/补充:Step 1 阳性对照改用
add_modifier 未知属性守卫;新增 Step 5b 补前序评审「磁盘往返未独立复核」;
Step 6/7 原脚本经排查为空断言,改用真实红/绿对照场景。

numerical_design.md §1.1/§1.2 补录属性购买消耗与 cast_delay_mod 的 soft
约束说明;路线图 E3 第 2 项勾除完成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:19:09 +08:00
joywayerandClaude Opus 5 d69389e09d feat(shop): 设计器「属性」页扩展 shop 段五字段
mode/curve 下拉存 key 不存双语显示串;UI.spin 的 min 取 0 以免 Range 的
抵消误差把浮点噪声写进权威数据文件(E3-① 实测);price_base 存 float
而非 int——JSON.parse_string 对所有 JSON 数字一律解析为 float,存 int
会使往返逐位比较恒假失败,且与 price_formula.gd 的 float() 读取一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:56:55 +08:00
joywayerandClaude Opus 5 ad98f916e9 feat(shop): 商店「属性」区独立子面板 UI + 4 语言 i18n 键
主商店面板已无空间容纳四行属性(面板 y=200~540,既有控件已占到 y≈522,仅剩约 18px),
经确认后改为独立子面板方案(参照 _setup_settings_ui 既有模式),不改动主面板任何既有控件坐标。

按 get_sellable_attrs() 动态生成行,加可售属性无需改 UI 代码。
禁用态显示具体原因(金币不足/已达上限)而非只灰掉按钮。
can_buy_attribute 注释补 i18n 债务说明(禁用原因暂未 tr(),见路线图 E7-③)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:46:05 +08:00
joywayerandClaude Opus 5 cc57980e63 docs(plan): Task4 坐标实测订正——商店面板放不下,改用独立子面板
计划里 y=450 起的坐标基于「现有控件止于 y=440」的假设,实测为错:
面板覆盖 y=200–540,现有控件已排到 y≈479,_shop_warn 在 y=502,
panel 内仅余约 18px,而标题+4 行需约 161px。

改用方案 B(独立子面板,照既有 _setup_settings_ui 结构):与项目
「主面板 + 按钮开子面板」的既有模式一致,且零坐标风险。方案 A 撑高面板
后离视口底仅 28px,后续新增控件会再次撞墙。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:38:46 +08:00
joywayerandClaude Opus 5 f9486083e3 docs(plan): 订正 Task3 吞血成因——补正提交 900eaea 说明中的失实描述
900eaea 的提交信息照抄了简报里「hp_max 买到 180 后即可复现」的说法,而
Task 3 实测 + 评审独立复现证明该路径不触发:_modifiers 为空时
remove_modifiers_from 提前返回,单次合并的 add_modifier 一步算出最终值,
中间没有可观察的陈旧 hp_max。

真实风险窗口是同进程内二次 apply_run(_modifiers 已有 shop_c 加成时)。
计划里补了三行复现数据与「写回归测试必须用场景 B 前提」的告诫——
按失实说法写的断言恒绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:34:07 +08:00
joywayerandClaude Opus 5 900eaea697 feat(shop): 货架 C 购买次数进局内存档(schema 2→3)
只存次数不存加成——加成是派生物,回读时经 _apply_attr_purchases 重建。
这也躲开了 E3-① 记录的坑:_modifiers 是 Array[Dictionary],而 JSON 回读的
无类型 Array 直接 = 赋值是运行时类型错误,必须 .assign();存派生源头则不涉及。

回读顺序:attr_purchases 必须早于 load_save_data。后者设 hp,而重建加成会经
_recompute_attrs 触发 hp = minf(hp, hp_max);顺序颠倒会拿未加成的 hp_max 去钳,
静默吞血(hp_max 可买到 180 后即可复现)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:27:23 +08:00
joywayerandClaude Opus 5 f8e02b0a0b fix(shop): pct 合并公式按 combine 分支取值,修正 inverse 属性算错
_apply_attr_purchases() 原公式 merged=(1+step)^n−1 只对 AttributeFormula 的
hybrid 分支(f=1+v)成立;inverse 分支用 f=1−v,四个可售属性里 cast_delay_mod
恰是 inverse+pct,实际把 f 算成 2−1.1^n 而非设计意图的 0.9^n——n=7 时应得
0.478,实得 ≈0.0513,可购买次数从设计的约 22 次被砍到约 7 次即撞 soft,且
全程 f≥0 走不到越界钳制分支,零诊断。

这是 Task 2 简报 spec 本身推导错误(简报只在 hybrid 下验证过等价性),非
实现偏差;评审已订正 spec 与计划(68e3edb)。现按订正后的公式补上按
combine 分支取值:hybrid 用 merged=(1+step)^n−1,inverse 用
merged=1−(1−step)^n,flat 与 combine 无关不变。

补测显式覆盖此前遗漏的 inverse+pct 组合(此前运行时验证只买过
move_speed/hp_max/cpu_limit,唯独没买过 cast_delay_mod——测试盲区精确盖住
了 bug 所在处):n=1..3/7 逐点数值比对(容差 1e-6),并确认 soft 封顶购买
次数从 ~7 恢复到 ~22;hybrid/flat 对照组数值不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:19:23 +08:00
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
joywayer 25bdc83c93 docs(plan): Task2 Step6 的 git add 补上 player_stats.gd
预检时新增的 Step 0(PlayerStats 通用访问器)改了该文件,但当时没同步更新
Step 6 的提交命令。实现者发现并正确地把它纳入提交。
2026-08-03 11:12:17 +08:00
joywayerandClaude Opus 5 622da4a18e feat(shop): 货架 C 状态与购买——shop 段驱动可售集合,唯一写入点重建加成
可售集合纯数据驱动:attributes.json 的 shop 段缺失即不可售,将来解除 7 个
被阻塞属性的前置后只需加一段 JSON,零代码。

PlayerStats 新增通用生效值访问器 get_attr_value(attr_id),消除商店/UI 侧
本应出现的三处硬编码 match attr_id;_attr_effective 与裸字段在同一处
(_recompute_attrs)更新,避免两者不同步导致显示值与生效值脱节。

_attr_purchases 只存次数;每属性至多一条 _modifiers、value 为合并值
(pct 下 n 次 +step 等价于单条 (1+step)^n − 1)。这样出售退款只需次数减一后
重算,不必给 PlayerStats 新增按条撤销的 API。

所有改动次数的路径都收在 _apply_attr_purchases()——两份状态不同步会产生
「显示买了 3 次但加成只有 2 次」且无任何诊断,收敛写入点是唯一防线。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:11:08 +08:00
joywayerandClaude Opus 5 6aeadb84b8 fix(shop): PriceFormula 大购买次数溢出静默塌陷为 1;同步计划文档残留类型名
geometric 曲线在 purchased 较大时(如 n=300,base=60 growth=1.15)raw
虽仍是合法有限 double(约 9.7e19),但已超出 int64 安全范围,roundi()
对此行为未定义/环绕,经 maxi(...,1) 静默塌陷成 1——方向与「买得越多越
贵」相反,且零诊断。仅判断 is_finite(raw) 测不出这种情况(double 本身
溢出为 INF 要到 n≈5077 才发生,晚于 int64 溢出很多),故改为
`not is_finite(raw) or raw > 9.0e15` 双重判据,触发时 push_error 并钳
到统一上限,不再依赖具体常数断言(新增用例只断言单调性与「不再塌陷回
归」)。

同时补齐 docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md:64 遗漏的
`PriceFormula.Curve` → `PriceFormula.PriceCurve` 同步(enum 部分先前已
改,返回类型标注漏改),并全仓复核确认无其它残留。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:05:51 +08:00
joywayerandClaude Opus 5 359da9f24f docs: 订正 enum Curve → PriceCurve(与 Godot 内置 Curve 资源类冲突)
Task 1 实现时发现:enum Curve 在 4.7.1 解析期即报
"member Curve shadows a native class"——与引擎内置的 Curve 资源类同名。
实现者做了独立最小复现,确认与 class_name 无关。

spec §2.1 与计划 Task 1 的代码块都写着 enum Curve,若不订正,Task 2 的简报
会从计划里抄到错误的类型名。下游一律用 PriceFormula.PriceCurve。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:56:57 +08:00
joywayerandClaude Opus 5 c6e89a0787 feat(shop): PriceFormula 定价模块——geometric / linear / flat 三曲线
定价集中于单一纯静态模块,无状态零依赖,故可脱离游戏进程单元断言
(与 AttributeFormula 同构)。模块不认识「属性/武器/装备」,只认识
{price_base, price_growth, curve},划分点在数据里——故货架 B 与出售退款
可直接复用,不必各写一套。

返回值下限 1:免费购买无意义,且 0 价会让「买不起」的判定失效。

偏离简报字面代码一处:枚举由 `Curve` 改名 `PriceCurve`。Godot 4.7.1
拒绝声明与引擎全局类同名的嵌套枚举("member Curve shadows a native
class",与 class_name 是否注册无关),简报/设计文档中的 `enum Curve`
在本引擎版本下无法编译,属简报代码本身的缺陷而非誊抄误差。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:55:35 +08:00
joywayerandClaude Opus 5 24948c77f8 docs(plan): 预检订正——消除三处硬编码属性名,兑现「加属性零代码」
开工前扫描发现计划初稿违反了它自己的 Global Constraint:ShopManager
_effective_value() 与 combat_s2 的两个辅助各写了一个 match 硬编码四个属性名,
外加 4 个 ATTR_* 的 i18n 键。第 5 个属性加 shop 段后会「UI 生成了行、
但生效值读出 0 且名字是裸 id」——本特性的立身之本直接破功。

改为:PlayerStats 维护 _attr_effective 冷路径视图 + get_attr_value(id)
(热路径仍走裸字段,零开销不受影响);UI 的显示名读 attributes.json 已有的
display_name;ShopManager 暴露 get_attr_def()。三处 match 与 4 个 i18n 键全部删除。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:52:10 +08:00
joywayer e41a32b751 chore: .gitignore 忽略 .superpowers/(子代理工作流的临时工作区)
该目录存放 SDD 流程的账本、任务简报与评审包,属过程性临时产物,
不进版本控制;否则会长期挂在 git status 里成为噪声。
2026-08-03 10:39:12 +08:00
joywayerandClaude Opus 5 e069b76ee1 docs(plan): E3-② 货架 C 逐任务实现计划(7 任务 · 含回读顺序吞血风险)
Task0 建分支 / Task1 PriceFormula(TDD,含报错通道断言)/ Task2 shop 段 +
ShopManager 购买与唯一写入点 / Task3 存档 schema 2→3 / Task4 商店 UI +
4 语言 i18n / Task5 设计器 shop 段 / Task6 运行时验收 + 权威文档补录 + 回填。

Task3 写死一条顺序要求:apply_run 目前第一步就 load_save_data 设 hp,而属性
加成若在其后恢复,回读的 hp 会被尚未加成的 hp_max 钳掉——hp_max 可买到 133
之后即可复现静默吞血。Task6 Step6 专门验证该顺序。

自审订正 spec §3 影响文件表:原列 combat_manager「存档读写接入」是想当然,
profile_manager._collect_run_data 本就直接调 ShopManager.get_shop_seed(),
同样可直接调 get_attr_purchases_save(),不必绕一层。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:51:25 +08:00
joywayerandClaude Opus 5 06d66dbd1a docs(spec): E3-② 货架 C(属性购买)设计——PriceFormula 模块 + shop 段数据驱动
按用户指定的架构方向,定价由专门模块 PriceFormula 负责(与 AttributeFormula
同构:纯静态零依赖,故可脱离游戏进程单元断言)。模块不认识「属性/武器/装备」,
只认识 {price_base, price_growth, curve}——划分点在数据里,故货架 B 与出售退款
可直接复用。

可售集合纯数据驱动:attributes.json 的 shop 段缺失即不可售,自动覆盖 7 个
「已定义但被阻塞」的属性,将来解除前置只需加一段 JSON,零代码。

附完整角色属性盘点(6 类):框架内 4 个可售、权威表已定义未进框架 7 个、
PlayerStats 里权威表漏记的事实属性 2 个(mana_regen/mana_leech,权威给货架 C
举的「回蓝速度」正是前者)、资源与进度、法杖侧、全局配置。

查明 numerical_design §1.2 消耗清单缺属性购买一项,故定价属设计而非查表,
定稿后补录列为本设计的组成部分。

自审订正一处方向性错误:初稿称 cast_delay_mod 的饱和点高于 soft、需逐杖动态
判定——反了。inverse 属性数值更低=更快,三个饱和点全部低于 soft(0.1),正常
购买够不到,UI 只需 soft 一道闸。差点规定一个永远触发不了的功能。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:35:18 +08:00
joywayerandClaude Opus 5 d7420a284f docs(roadmap): 新增 E7-⑥ 计算模块化重构(用户 2026-07-31 指定的架构方向)
原则:凡涉及计算的都由专门功能模块负责,便于测试与配置。
AttributeFormula 是已验证的模板(纯静态零依赖,故不启动游戏就能单元断言)。

盘点出计算散落 8 处、仅 1 处已模块化。最严重的是伤害被劈成两半分居两个文件,
且两处都在扣护甲——目前不重复扣仅因调用点特意传 armor=0,而该约定只存在于
调用点,两个函数各自看都是完整公式。连击 +2%/层 也硬编码在管理器里。

记录立项时必须先决定的设计问题与热路径风险,避免实现期顺手定。
顺序:货架 C(含 PriceFormula)先做,本项独立立项。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:04:04 +08:00
joywayerandClaude Opus 5 9a5f2c1271 chore: 补提交 addons/godot_mcp 的 35 个 .gd.uid,与全项目约定对齐
此前 godot_mcp 是全项目唯一不跟踪 .uid 的目录:
  scripts/                51 .gd / 51 .uid
  addons/game_designer/   13 .gd / 13 .uid
  addons/godot_mcp/       35 .gd /  0 .uid  ← 唯一例外
且 .gitignore 无任何 .uid 规则,CLAUDE.md 亦要求新增 .gd 连同 .gd.uid 一并提交。

.uid 是稳定资源标识符,不跟踪则其它机器扫描时会重新生成不同 UID,
可能打断 uid:// 引用;Godot 官方亦建议提交。一并消除长期挂在
git status 里的 35 行噪声。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:00:25 +08:00
joywayerandClaude Opus 5 9361bf8767 refactor(attr): "core" 来源提为常量;_compute_attr 补 assert;注明 execute_sub 预算独立
MOD_SOURCE_CORE 常量取代四处裸字面量(combat_manager:275,276、combat_test:20,21)。
combat_manager:273 的注释自己就警告「漏掉 remove 会累积,且 hard:50 会把它封成一个
看起来合理的数字」——而 remove 那一侧的字面量若打错一个字符,产生的正是这个零诊断
故障:旧份额撤不掉、逐次累积、被硬上限封顶成常数。常量把它变成编译期错误。

_compute_attr 缺定义时返回 0.0 是消费侧的不对称:Task 4 已硬化生产侧(attribute_tab
的 _loaded 拒绝写出零载荷),但手删 JSON 里的 cast_delay_mod 仍会让生效间隔变 0
(每物理帧施法一次)、删 move_speed 则玩家不能动。它有 push_error,但运行时 push_error
到不了任何日志通道(本期已实测),故表现为一块莫名其妙的砖。按 _recompute_attrs 空定义
分支的同款模式补 assert:release 编译掉、开发期立刻中断。

spell_evaluator:332 的新注释说「生效 cpu_limit 已含法杖份额」,读者可能推断整个 VM 都按
cpu_limit 走,但 execute_sub 的子荷载预算仍是独立的 MAX_OPS_PER_CPU * 2(=80)。补一句
注明其独立且属既有行为(改前同样脱节),不改 execute_sub 的行为。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:37:15 +08:00
joywayerandClaude Opus 5 84c10725e2 docs: 订正 wand_fast 饱和点 0.05→0.0667;roadmap 关闭已决定项并澄清移速表述
【订正】wand_fast(0.25s) 的饱和点按公式 (1/60)/0.25 = 0.0667,不是 0.05。
numerical_design:21 的标注与它自己上一句的公式直接矛盾,spec/plan 另有三处同源
错误标注——均派生自一句口头转述,实测表里记的 0.0667 一直是对的。

结论也随之改对,不只是改数字:hard=0.05 使 wand_basic(饱和 0.0333) 与
circuit_fork(0.0278) 无无效区间,但 wand_fast 仍剩 [0.05, 0.0667) 这段买不到东西
(较原 hard:0.01 的 6.7 倍缩至 1.33 倍)。仍取 0.05 的理由:饱和点 = (1/60)/法杖
基准间隔,随杖而异,不存在对所有杖都最优的单一 hard——取 0.0667 让 wand_fast 干净
会使另两把杖够不到自己的饱和点,反而制造新的不可达区间。hard 数值不变。

顺带说明实测表里 wand_fast@0.0667 读到 2 帧的原因:采样点比真实边界 1/15 高 3.3e-5
(T 上高 8.3e-6 s),再叠加整帧倍数多花一帧的浮点余量效应,故实际 1 帧上界略低于
0.0667。数据没错,是标签错了。

【roadmap】E3-① 下的 cast_delay_mod 条目仍记为「待人定」并指向两个选项,而该决定
已在 ba7c885 关闭——roadmap 是查「E3 还剩什么」的索引,留着会把已关闭的决定重新打开。
改为已处理,并注明彻底消除需让 hard 逐杖化(挪进 cores.json),属独立立项。

【roadmap】「实测 200→300 px/s」读起来像发版移速由 200 改成 300,与同批改动里
numerical_design 的订正(发版是 200,300 是从未被读过的纸面值)直接冲突。改写为
「发版基准仍是 200;实测手段是临时挂 +50% 加成使生效值变 300」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:36:53 +08:00
joywayerandClaude Opus 5 ba7c885291 fix(attr): cast_delay_mod 硬上限 0.01 → 0.05,消掉两把主力杖的射速无效区间
_handle_auto_cast 用 if 而非 while,每物理帧至多施法一次,故本属性存在饱和点
= (1/60) / 法杖基准间隔:wand_basic(0.5s) ≈ 0.033、wand_fast(0.25s) ≈ 0.05。
原 hard: 0.01 在两把主力杖上都深深落在饱和区内——玩家把 cast_delay_mod 从
0.033 一路买到 0.01,射速一点没变,属"承诺了循环交付不了的东西"。

取 0.05 使 wand_fast 恰在硬上限处饱和、wand_basic 全程无无效区间。
边界实测(同一套 _physics_process 帧距扫描):wand_fast@0.05 = 1 帧/次(60/s)、
wand_basic@0.05 = 2 帧/次(30/s)、请求 0.01 被 maxf(hard,…) 钳回 0.05 后帧距不变、
soft(0.1) 对照仍 3 帧/次(20/s)、circuit_fork@0.05 = 2 帧/次。

未加任何代码钳制:下限已由 attributes.json + AttributeFormula 的 maxf(hard,…)
强制,代码里再加一个会形成重复权威,设计师调 hard 时立刻脱同步。

spec 新增 §2.2b 收录完整实测数据集(饱和点公式与三把杖的值、soft→hard 是台阶
而非曲线、周期恒为 ceil(T×60) 且整帧倍数多花一帧故实际射速恒 ≤ 名义值)——
结论易得而数据难得,故一并留档。circuit_fork 的饱和点 0.0278 标注为公式推导。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:18:46 +08:00
joywayerandClaude Opus 5 d259205db2 docs(plan): 补跑守卫的另外三条进入路径,六条全部由运行时而非眼读覆盖
resume_game / _apply_padded(买卡) / apply_wand_save_data 三条原本只经静态核对,
现已在运行时跑到:均 0 条 cpu_limit=0 报错,且 deck/属性状态正确。
连同已跑的 start_game / switch_core / combat_test,六条齐全。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:10:24 +08:00
joywayerandClaude Opus 5 f3d07c04f8 docs(plan): 订正 Step 7b 报错归因措辞——MCP 注入脚本的解析告警不是探针
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:06:49 +08:00
joywayerandClaude Opus 5 04ca402f3c docs(plan): 回填属性系统计划为实际执行版,勾除除合并决策外全部步骤
按 Task 5 Step 8:每个因评审而变的代码块与断言脚本都替换为真正跑通的版本,
逐处附「原写法为何不可用」——这才是回填的持久价值,否则下次复跑喂出的是
过时代码和可能已失效的断言(归航那期踩过)。

主要回填:AttributeFormula 的 pct 越界守卫与静态类型;PlayerStats 的 add_modifier
校验 / hp 钳制 / reset_for_run 清空 / 空定义分支用 assert;守卫从施法处移到 equip_wand;
cast_delay_mod 改开火时生效(原计划的装备时折叠会让局中加成到下次换杖才生效,
正好废掉货架 C);补 combat_test 注入这个原计划遗漏的消费方;属性页 SpinBox 下限、
_loaded 拒写回、autowrap、两条 push_warning。

订正计划自己的一句假话:原写「四个属性的值全部落在 0.01 格点上,故往返无量化损失」。
实测 min=-99999 时 0.1→0.100000000005821、0.01→0.00999999999476131,二者恰恰都在
0.01 格点上——成因是 Range 吸附式在 min 与 step 相差七个数量级时的抵消误差,与格点
无关;而 0.0001 容差比该漂移大七个数量级,原断言在有 bug 的版本上也是绿的。改为对
落盘文本逐字节比较。

Task 5 另记两条工具坑:运行中游戏的 push_error/push_warning 不进 MCP 的两个日志通道
(只有 print 进),故「跑一局看日志无报错」本身是空断言,必须读编辑器 Debugger 错误页
并先用故意错误做正对照;execute_game_script 不支持 await、三引号会被外层包装破坏。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:05:40 +08:00
joywayerandClaude Opus 5 9f71108719 docs(attr): 守卫注释改引函数名而非会腐烂的行号;调试场景补注入顺序理由
Task 3 复审转来的两条 Minor。player_manager 守卫注释里的
「player_stats.gd:173/191」是行号引用,改动一次就失效,改为
_load_attr_definitions / add_modifier 两个函数名。
combat_test 的注入原先只有不变式没有理由,补上 remove→add→equip_wand
三者的先后依据(累积防护 + 守卫读的是注入结果)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:55:08 +08:00
joywayerandClaude Opus 5 1aeae19187 docs: 订正 move_speed 300→200 与 cpu_limit「从不读」告警;路线图 E3-① 勾除并订正现状
move_speed 300 从未被任何代码读取,是纸面孤值;200 自 S0 沿用并已围绕它调校
20 波内容与 Boss 弹幕密度,以既成事实为准。cpu_limit 行的「代码硬编码 40×5、
从不读 cpu_limit」告警随本次接线失效,改记生效值构成(玩家基准 0 + 法杖 3–8)
与 hard(50) 作用于加总后的总值;基准值一并由 5 改为 0,否则重蹈 move_speed 的
「纸面值与实现长期脱节」。

路线图 E3 现状原写「player_stats.gd 有 resistance 等占位属性(4/5 stats 未接线)」
与事实不符——权威属性表是 11 个,armor/resistance 根本不在表内,是实现先于设计的
孤儿字段。E3-① 勾除并标注实际范围(只做框架 + 三条死数据),attunement_×4 /
luck / recharge_speed_mod 三项延后各附理由;顺带记 cast_delay_mod 的饱和区待人定。
E1 现状里「玩家侧抗性仍占位」一句同因失效,一并订正。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:54:22 +08:00
joywayerandClaude Opus 5 7884e43147 fix(attr): 补齐属性页最后两条静默改写数据的诊断,并订正两处注释
1. 条目缺失时无任何诊断(上一轮 `and not d.is_empty()` 引入的回归)。
   attributes.json 格式良好但少了 hp_max 时,_loaded 仍为 true、保存照常,
   _collect 写出 {"base":0,"soft":0,"hard":0,"combine":"hybrid"}。更糟的是这个键
   从此「存在」,player_stats.gd 的「缺少属性」报错也不再触发,hp_max=0 完全静默
   进游戏;而 hard=0 在 hybrid 下意味着不钳制,连兜底都没有,玩家瞬死。
   改为 if d.is_empty() 单独 push_warning,与 combine 那条对称。

2. min=0.0 引入的负值静默钳制同样无诊断("move_speed":{"base":-50} → 写回 0)。
   运行时本就把生效值钳到 ≥0,但静默改写权威数据正是 combine 警告要防的事,
   并入同一警告块保持处理对称。

3. _clean 的注释末句由描述改为禁令。原文「当前 12 个值并不依赖本函数」是事实,
   但读起来像绿灯,而删掉它一直无害——直到有人放宽 min,也就是它存在的唯一场景。

4. _collect 的注释夸大了:未知键只有是对象时才存活,顶层标量键会让 _load 整体
   拒绝加载。改为「保留未知属性条目」。

实测:缺 hp_max 与 base=-1234.5 两条警告均如实发出;未改动的 attributes.json
连建三次零警告零报错(误报比不报更糟,已专门验证)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:35:12 +08:00
58 changed files with 2567 additions and 261 deletions
+3
View File
@@ -25,3 +25,6 @@ gdextension/**/~*
gdextension/**/*.obj.import
**/.sconsign.dblite
compile_commands.json
# superpowers 子代理工作区(账本/简报/评审包,git-ignored scratch
.superpowers/
+62 -7
View File
@@ -9,9 +9,14 @@ const PATH = "res://data/attributes.json"
const COMBINE_KEYS = ["hybrid", "inverse", "add_int"]
const COMBINE_LABELS = ["混合 hybrid", "反向 inverse", "整数加 add_int"]
const ATTR_ORDER = ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]
const MODE_KEYS = ["flat", "pct"]
const MODE_LABELS = ["固定量 flat", "百分比 pct"]
const CURVE_KEYS = ["geometric", "linear", "flat"]
const CURVE_LABELS = ["几何 geometric", "线性 linear", "固定 flat"]
var _data: Dictionary = {}
var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton}
var _shop_rows: Dictionary = {} # attr_id → {"mode","step","price_base","price_growth","curve"} 控件
var _status: Label
var _loaded: bool = false # 加载失败时禁止写回,见 _save
@@ -32,15 +37,45 @@ func _ready() -> void:
var sp_hard := UI.spin(0.0, 99999.0, 0.01, float(d.get("hard", 0.0)))
var combine_key := String(d.get("combine", "hybrid"))
var idx: int = COMBINE_KEYS.find(combine_key)
if idx < 0 and not d.is_empty():
# 不硬拒(打字错误不该让整页打不开),但必须留下诊断:保存会把改写成 hybrid
push_warning("attribute_tab: 「%s」的 combine「%s」无法识别,下拉已回落到 hybrid,保存将覆盖原值" % [attr_id, combine_key])
# 本页凡是「静默改写权威数据」的路径都必须留下诊断,一条都不能漏——
# 保存会把改写结果写回 attributes.json,而下游 PlayerStats 只会照单全收
if d.is_empty():
# 条目缺失时占位的 0 一旦被保存,键就「存在」了,
# player_stats.gd 的「缺少属性」报错从此不再触发,hp_max=0 静默进游戏
push_warning("attribute_tab: %s 缺少属性「%s」,本页以 0 值占位,保存会把 0 写进文件" % [PATH, attr_id])
else:
if idx < 0:
# 不硬拒(打字错误不该让整页打不开),但必须留下诊断:保存会把它改写成 hybrid
push_warning("attribute_tab: 「%s」的 combine「%s」无法识别,下拉已回落到 hybrid,保存将覆盖原值" % [attr_id, combine_key])
for f in ["base", "soft", "hard"]:
if d.has(f) and float(d[f]) < 0.0:
push_warning("attribute_tab: 「%s」的 %s = %s 为负,输入框下限 0 已将其钳制,保存会把 0 写进文件" % [attr_id, f, str(d[f])])
var op := UI.opt(COMBINE_LABELS, idx if idx >= 0 else 0)
grid.add_child(sp_base); grid.add_child(sp_soft); grid.add_child(sp_hard); grid.add_child(op)
_rows[attr_id] = {"base": sp_base, "soft": sp_soft, "hard": sp_hard, "combine": op}
add_child(HSeparator.new())
add_child(UI.header("🛒 货架 C — 可售配置(无 shop 段 = 不可购买)"))
var g2 := GridContainer.new(); g2.columns = 6; add_child(g2)
for h2 in ["属性", "增量类型 mode", "每级 step", "首价 price_base", "涨幅 growth", "曲线 curve"]:
var l2 := Label.new(); l2.text = h2; l2.modulate = Color(0.7, 0.8, 1.0)
g2.add_child(l2)
for attr_id2 in ATTR_ORDER:
var sp: Dictionary = _data.get(attr_id2, {}).get("shop", {})
g2.add_child(UI.cell_label(String(_data.get(attr_id2, {}).get("display_name", attr_id2))))
var mi: int = MODE_KEYS.find(String(sp.get("mode", "flat")))
var op_mode := UI.opt(MODE_LABELS, mi if mi >= 0 else 0)
var sp_step := UI.spin(0.0, 99999.0, 0.01, float(sp.get("step", 0.0)))
var sp_pbase := UI.spin(0.0, 99999.0, 1.0, float(sp.get("price_base", 0.0)))
var sp_grow := UI.spin(0.0, 99999.0, 0.01, float(sp.get("price_growth", 1.0)))
var ci: int = CURVE_KEYS.find(String(sp.get("curve", "geometric")))
var op_curve := UI.opt(CURVE_LABELS, ci if ci >= 0 else 0)
g2.add_child(op_mode); g2.add_child(sp_step); g2.add_child(sp_pbase)
g2.add_child(sp_grow); g2.add_child(op_curve)
_shop_rows[attr_id2] = {"mode": op_mode, "step": sp_step,
"price_base": sp_pbase, "price_growth": sp_grow, "curve": op_curve}
add_child(HSeparator.new())
var tip := Label.new()
tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。"
tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。cpu_limit 的 mode 必须是 flatadd_int 拒绝 pct);shop 段缺失即该属性不可购买。"
# dock 的 ScrollContainer 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句)
tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
tip.modulate = Color(0.7, 0.7, 0.7)
@@ -69,12 +104,14 @@ func _load() -> void:
## Range 用 round((v - min) / step) * step + min 吸附取值。min = -99999 与 0.01 的 step
## 相差七个数量级,此式在此发生抵消误差(0.01 → 0.00999999999476),直接写回会把噪声
## 固化进 JSON。本页已把 min 收紧到 0.0,当前 12 个值全部逐位精确、并不依赖本函数;
## 保留它是给将来真需要宽区间(含负值)的属性的廉价保险
## 固化进 JSON。本页 min = 0.0 12 个值原本就逐位精确,本函数当前不改变落盘结果
##(唯一副作用:0.1 在内存里低 1 ULPstringify 仍输出 0.1
## 勿因此删除 —— 一旦某属性需要负值 / 宽区间而放宽 min,抵消误差立即回来。
static func _clean(v: float) -> float:
return snappedf(v, 0.000001)
## 表单 → 字典(合并式:以 _data 为基底保留未知键)
## 表单 → 字典(合并式:以 _data 为基底保留未知属性条目,以及条目内未列入表单的键;
## 顶层标量键不在此列——_load 已因它们整体拒绝加载)
func _collect() -> Dictionary:
var out: Dictionary = _data.duplicate(true)
for attr_id in ATTR_ORDER:
@@ -87,6 +124,24 @@ func _collect() -> Dictionary:
entry["hard"] = _clean(r["hard"].value)
# 存 key,不存双语显示串;selected 恒为 0..2UI.opt 构造时已 clampi,本页此后不再赋值)
entry["combine"] = COMBINE_KEYS[r["combine"].selected]
var r2: Dictionary = _shop_rows.get(attr_id, {})
# 「无 shop 段即不可售」是货架 C 的核心不变量(ShopManager.get_sellable_attrs 靠它筛选
# 可售集合)。_shop_rows 对 ATTR_ORDER 里每个属性都无条件建了控件行(哪怕原本没有 shop
# 段,行以 0 值占位),所以不能只凭 r2 非空就写回;必须再确认 _data 里原本就有这一段,
# 否则会把 price_base=0 的空段写进 JSON——PriceFormula 把 0 价地板钳到 1G
# 一个零效果属性从此以 1G 可无限购买,直接违反上述不变量。
var had_shop: bool = _data.get(attr_id, {}).get("shop", null) is Dictionary
if not r2.is_empty() and had_shop:
var shop_entry: Dictionary = entry.get("shop", {}).duplicate(true)
shop_entry["mode"] = MODE_KEYS[r2["mode"].selected] # 存 key 不存显示串
shop_entry["step"] = _clean(float(r2["step"].value))
# 注意:不用 int()——JSON.parse_string 对所有 JSON 数字(含无小数点的字面量,
# 如源文件的 "price_base": 120)一律解析为 float,若此处存 int 则往返比较时
# str(120) != str(120.0) 恒假失败,与 price_formula.gd 的 float() 读取方式一致
shop_entry["price_base"] = _clean(float(r2["price_base"].value))
shop_entry["price_growth"] = _clean(float(r2["price_growth"].value))
shop_entry["curve"] = CURVE_KEYS[r2["curve"].selected]
entry["shop"] = shop_entry
out[attr_id] = entry
return out
+1
View File
@@ -0,0 +1 @@
uid://cin2nsghxc2ls
@@ -0,0 +1 @@
uid://c8exes22tfkqj
@@ -0,0 +1 @@
uid://bdigkr0emai80
@@ -0,0 +1 @@
uid://bonthf2yoeyhw
@@ -0,0 +1 @@
uid://bcqa6ux42fs1w
@@ -0,0 +1 @@
uid://bijs5xgewwx3a
@@ -0,0 +1 @@
uid://burglba33dtlu
@@ -0,0 +1 @@
uid://bwlmjlivjq4wt
@@ -0,0 +1 @@
uid://drnul0blh6wv0
@@ -0,0 +1 @@
uid://da135mgraliib
@@ -0,0 +1 @@
uid://bwjnrsson0em
@@ -0,0 +1 @@
uid://dxrh88r7hn546
@@ -0,0 +1 @@
uid://7ffhpvf8g87g
@@ -0,0 +1 @@
uid://dwu2yrh3e5sxu
@@ -0,0 +1 @@
uid://b58ilnv1n62ak
@@ -0,0 +1 @@
uid://d2ng2v8ayfehs
@@ -0,0 +1 @@
uid://b3ogc0lfxeve6
@@ -0,0 +1 @@
uid://c4loc8wwb4qe8
@@ -0,0 +1 @@
uid://bmyqrhfpp382w
@@ -0,0 +1 @@
uid://diyd0jmvjhevd
@@ -0,0 +1 @@
uid://or4r1hf3581w
@@ -0,0 +1 @@
uid://3dbek7farv50
@@ -0,0 +1 @@
uid://f0b3g6xkqbtd
@@ -0,0 +1 @@
uid://c1rd4ny2dftmj
@@ -0,0 +1 @@
uid://dxedxhq1sfhsq
@@ -0,0 +1 @@
uid://bwig0wr0jlne2
@@ -0,0 +1 @@
uid://cmsrsh4xgw30p
@@ -0,0 +1 @@
uid://b0l5v3g6gnkju
@@ -0,0 +1 @@
uid://yc5bqv2o84nn
@@ -0,0 +1 @@
uid://d123ueb43a5sc
+1
View File
@@ -0,0 +1 @@
uid://chtol501pvkq3
+1
View File
@@ -0,0 +1 @@
uid://by5xvwsbrofb
+1
View File
@@ -0,0 +1 @@
uid://b0vs2krneteax
@@ -0,0 +1 @@
uid://bxtcaicg3ko7l
+1
View File
@@ -0,0 +1 @@
uid://bv7imd44hcnwy
+4 -4
View File
@@ -1,6 +1,6 @@
{
"cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" },
"move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" },
"cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.01, "combine": "inverse" },
"hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" }
"cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int", "shop": { "mode": "flat", "step": 1, "price_base": 120, "price_growth": 1.30, "curve": "geometric" } },
"move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid", "shop": { "mode": "pct", "step": 0.08, "price_base": 70, "price_growth": 1.15, "curve": "geometric" } },
"cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse", "shop": { "mode": "pct", "step": 0.10, "price_base": 90, "price_growth": 1.20, "curve": "geometric" } },
"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" } }
}
+9 -4
View File
@@ -17,11 +17,11 @@
| :--- | :--- | :--- | :--- | :--- | :--- |
| `hp_max` | 最大生命 | 100 | 2000 | - | |
| `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 |
| `move_speed` | 移动速度 | 300 | 600 | 800 | 像素/秒 |
| `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | 0.01 | 越低越快,乘算系数 |
| `move_speed` | 移动速度 | **200** | 600 | 800 | 像素/秒。⚠️ **2026-07-31 订正**:原写 300 从未被任何代码读取过,是纸面孤值;实际实现自 S0 起即为 200(`player_manager.gd``const MOVE_SPEED`,现已改为读 `data/attributes.json`),20 波内容、Boss 弹幕密度、无敌帧 0.5 s 均按此值调校。此处以既成事实为准 —— 改回 300 等于用未经验证的数字推翻一整轮已调校的内容。理由详见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 |
| `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | **0.05** | 越低越快,乘算系数。⚠️ **2026-07-31 由 0.01 上调至 0.05**`player_manager._handle_auto_cast``if` 而非 `while`,**每物理帧至多施法一次**,故本属性存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic`(0.5 s) ≈ **0.0333**`wand_fast`(0.25 s) ≈ **0.0667**`circuit_fork`(0.6 s) ≈ 0.0278。**饱和点随杖而异,故不存在对所有杖都最优的单一 `hard`**:要让 `wand_fast` 无无效区间需 `hard ≥ 0.0667`,但那会让 `wand_basic` 够不到自己的 0.0333,反而制造新的不可达区间。取 `0.05` 是折中 —— `wand_basic``circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)` 这一小段买不到东西;相对原 `0.01`(两把主力杖都深陷饱和区、从 0.0333 一路买到 0.01 射速纹丝不动)是大幅改进。实测数据(可达档位是台阶而非曲线、周期恒为 `ceil(T×60)`)见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。**2026-07-31 补充(货架 C)**:本属性的三个饱和点(`wand_basic` 0.0333 等)**全部低于货架购买软上限 `soft`(0.1)**,故常规购买够不到饱和区,`soft` 才是玩家实际能碰到的约束——运行时实测买到 `soft` 门槛需 22 次购买,此时帧距仍为 3 帧(未饱和),验证见 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md` §2.3。 |
| `recharge_speed_mod` | 充能速度修正 | 1.0 (100%) | 5.0 | - | 越低越快 |
| `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 |
| `cpu_limit` | 运算力上限 | 5 | 20 | 50 | `SpellEvaluator` 实际 MAX_OPS = `cpu_limit × 40`(默认 5 → 200 步;软上限 20 → 800 步;硬上限 50 → 2000 步)。升级此属性可执行更复杂的法术链,与 `implementation_plan.md §2.2``MAX_OPS_PER_CPU = 40` 常量对应。 ⚠️**实现现状(2026-07-20)**代码硬编码 `MAX_OPS = 40×5 = 200`**从不读 cpu_limit**——升级此属性对施法预算无效(待修,本设计更优)。 |
| `cpu_limit` | 运算力上限 | **0**(玩家侧) | 20 | 50 | `SpellEvaluator` 实际 MAX_OPS = `生效 cpu_limit × 40`,与 `implementation_plan.md §2.2``MAX_OPS_PER_CPU = 40` 常量对应。**生效值 = 玩家侧基准 0 + 法杖份额(`cores.json` 逐杖 38,以 `source="core"` 的加成来源接入)+ 未来的玩家加成**;`hard`(50) 作用于**加总后的生效值**,故法杖份额也受硬上限约束。玩家侧基准取 0 而非 5 是刻意的:生效值已含法杖的 3–8,取 0 使小木法杖仍为 5 → 200 步(现有平衡零改动),取 5 会让所有法杖的执行预算翻倍;语义上玩家属性是"全局加成",基准 0 正是纯加成语义。 ✅ **2026-07-31 已修复**(原 ⚠️「代码硬编码 MAX_OPS = 40×5 = 200从不读 cpu_limit」现已失效):`spell_evaluator.gd` 改读 `MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)``cores.json` 逐杖配置的运算力自此生效(高速法杖 3 → 120 步、小木 5 → 200、矩阵板 8 → 320,运行时实测)。设计见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 |
| `attunement_fire` | 火焰精通 | 0 | 50 | 100 | 每点:火焰伤害 +3%(乘算),火焰状态(点燃/爆燃)触发概率 +2%。影响 `damage_type=FIRE` 的所有投射物。 |
| `attunement_ice` | 冰霜精通 | 0 | 50 | 100 | 每点:冰霜伤害 +3%,冰冻/减速触发概率 +2%。 |
| `attunement_lightning` | 雷电精通 | 0 | 50 | 100 | 每点:雷电伤害 +3%,麻痹/连锁导电触发概率 +2%。 |
@@ -31,13 +31,18 @@
* **金币 (Gold)** (代号 `G`)
* **来源**: 击杀怪物 (1-5G),波次结算 (100G + 10%利息)。
* **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*)。
* **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*),购买属性 (60-120G 起,几何递增,见下)
* **利息上限**: 10% 利息计算时,单次波次奖励上限为 **100G**(即持有 1000G 以上部分不再产生额外利息)。防止后期经济失控膨胀。
* **膨胀控制**: 商店刷新价格每次增加,波次结束后重置。金币不捡 5 秒后消失,逼迫玩家移动拾取。
- **刷新价格公式(F11 补充,P6-N23)**`刷新费用 = 20 + (本波已刷新次数 × 10)`(单位:G)。
即第 1 次刷新 20G,第 2 次 30G,第 3 次 40G,以此类推;波次结算后重置为 20G。
设计意图:前 1-2 次刷新成本低,鼓励灵活换货;反复刷新代价指数感,抑制"无脑刷新"。
实现:`ShopManager` 维护 `_refresh_count: int`(波次内),`WAVE_COMPLETE` 事件后清零。
- **属性购买价格公式(货架 C,2026-07-31****购买属性 (60-120G 起,几何递增)**——各属性首购价与成长曲线见
`data/attributes.json``shop` 段(`hp_max` 60×1.15ⁿ / `move_speed` 70×1.15ⁿ / `cast_delay_mod` 90×1.20ⁿ /
`cpu_limit` 120×1.30ⁿ,`n` = 已购次数,首购 `n=0`)。**按本局该属性累计购买次数递增,不随波次重置**
(与刷新费用相反——刷新的价值每次相同故线性递增即可,属性购买的价值在复利)。上限为各属性的 `soft`
(「常规来源可达上限」)。设计依据见 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md` §2.2。
* **经验值 (XP)**
* **升级曲线(权威公式 P6-N16**
@@ -40,7 +40,7 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置,
**目标**:让子弹的 pierce 以外的深度维度真正生效,使元素、抗性、轨迹类词条与核心特性产生行为差异。
**现状(代码复核 2026-07-23**
- ~~元素抗性~~**完成**2026-07-24`feat/element-resistance`):`apply_damage_from_context``ctx.damage_type``_RESIST` 表传入 `calc_damage(0, res)``enemies.json` 每型可选 `resistances{元素名:-0.9~0.9}`(支持负=弱点加伤);设计器敌人页加 6×5 抗性网格。MCP 实测:抗火0.5→半伤、弱冰-0.5→1.5×、抗性+护甲叠加45。⚠️ **已知限制**DOT 伤害(`status_manager.gd:116` 直调 `apply_damage`)暂不享元素抗性(架构上 DOT 不走 context,见 E2)。(`player_stats.gd:23 resistance` 玩家侧抗性仍占位。)
- ~~元素抗性~~**完成**2026-07-24`feat/element-resistance`):`apply_damage_from_context``ctx.damage_type``_RESIST` 表传入 `calc_damage(0, res)``enemies.json` 每型可选 `resistances{元素名:-0.9~0.9}`(支持负=弱点加伤);设计器敌人页加 6×5 抗性网格。MCP 实测:抗火0.5→半伤、弱冰-0.5→1.5×、抗性+护甲叠加45。⚠️ **已知限制**DOT 伤害(`status_manager.gd:116` 直调 `apply_damage`)暂不享元素抗性(架构上 DOT 不走 context,见 E2)。(~~`player_stats.gd:23 resistance` 玩家侧抗性仍占位~~ —— **2026-07-31 订正**:该字段零消费方且不在权威属性表内,属实现先于设计的残留,已随 E3-① 删除;玩家侧减伤真要做时按货架 C 属性词条立项,见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.6。)
- ~~连锁/弹跳~~**完成**2026-07-24`feat/bullet-bounce`):镜像 pierce 管线——`CastStats.bounce_add``_apply_modifier` 折叠 → `_push_projectile` 写冷数据 → `_check_collision` 命中转向最近未访问敌(`_find_nearest_unvisited`)、`damage_mult×decay` 递减、`visited_targets` 防回跳、命中循环跳过已访问防密集群重复命中。bounce 优先于 pierce(耗尽后落 pierce)。`modifier_bounce` 词条上架。MCP 实测:bounce=2→3 敌 100/90/81、速度重定向证明优先级、折叠 bounce_add=2。
- ~~归航~~**完成**2026-07-30`feat/bullet-homing`):镜像 bounce 管线——`CastStats.homing_add`(float) → `_apply_modifier` 折叠 → `_push_projectile` 写冷数据 `homing_strength/homing_range/homing_target_id``_gd_integrate` 在位置积分**之前**调 `_apply_homing`,用 `wrapf` 取最短转向、`clampf``strength*delta` 限角速度、`rotated()` 保速率。目标为**发射后锁定**:仅首帧与目标死亡时经 `_find_nearest_unvisited` 重选;bounce 命中重定向时改写 `homing_target_id`bounce 选目标、homing 追上去)。`modifier_homing` 词条上架。MCP 实测见下方「⚠️ 有意偏离」与 spec §5。
- ⚠️ **有意偏离本文档原方案**:本行原写「`_gd_integrate` 消费 `_enemy_pos_snapshot`」,实现**没有**消费它,而是**删除**了 `_enemy_pos_snapshot` 及其唯一生产者 `EnemyManager.fill_pos_snapshot`。理由:最终设计采用**按 entity_id 锁定目标**(轨迹可读、目标死亡有明确判据 `get_pos_by_id` 哨兵),而该快照**按槽位存位置、不含 entity_id**,结构上支撑不了锁定语义;接上它反而要每帧 O(敌人数) 重扫最近点。净效果是**减少**一份每帧 O(敌人数) 开销。
@@ -81,15 +81,24 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置,
**目标**:把商店从"只卖法术"扩成完整 roguelite 经济:核心抽取、属性升级、出售退款,配合玩家属性成长系统。
**现状(代码复核 2026-07-23**`shop_manager.gd` 无 shelf/sell/core/attr 任何字段(grep 零匹配)→ **仅货架 A(法术抽取)**`player_stats.gd``resistance` 等占位属性(4/5 stats 未接线)。无出售退款(G5)。
**现状(代码复核 2026-07-23;属性部分 2026-07-31 订正**`shop_manager.gd` 无 shelf/sell/core/attr 任何字段(grep 零匹配)→ **仅货架 A(法术抽取)**。无出售退款(G5)。
- ⚠️ **2026-07-31 订正**:本行原写「`player_stats.gd``resistance` 等占位属性(4/5 stats 未接线)」,**与事实不符**。权威属性表(`docs/design/numerical_design.md` §1.1)是 **11 个属性**`hp_max`/`mana_max`/`move_speed`/`cast_delay_mod`/`recharge_speed_mod`/`luck`/`cpu_limit`/`attunement_×4`),`armor`/`resistance` **根本不在表内** —— 它们是实现先于设计的孤儿字段(零消费方),已随子计划 ① 删除。真实缺口是:三条**死数据**(`cpu_limit`/`move_speed`/`cast_delay_mod`,消费方已存在只是没接上,子计划 ① 已修)+ 三类**未立项新机制**(`attunement_×4`/`luck`/`recharge_speed_mod`)。详见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §0/§1。
**设计来源**`docs/design/game_design.md §5`(三货架)、`numerical_design.md`(属性曲线、退款比例)。
**依赖**:货架 C 依赖「属性词条系统」先落地;核心抽取复用已有 `cores.json`/`make_core_by_id`
**切分(建议 4 个子计划,属性词条优先)**:
1. **属性词条系统**:定义 4/5 缺失 player stats(如暴击/急速/范围/吸血/减伤)+ 数据驱动加成来源;PlayerStats 接线到伤害/射速/…;`attributes.json` + 设计器页。M
2. **货架 C(属性购买)**:商店可购属性词条(消耗金币/以太),叠加到 PlayerStats。S~M。
1. ~~**属性词条系统**~~**完成**2026-07-31`feat/player-attributes`spec/plan `docs_dev/{specs,plans}/2026-07-31-player-attributes*`):`AttributeFormula` 纯静态公式模块(`hybrid` 连乘 / `inverse` 反向下限 / `add_int``pct` + `pct` 越界钳 0);`PlayerStats``_attr_def` + `_modifiers` 并把公式结果写回静态类型裸字段(读取端零开销);`add_modifier` / `remove_modifiers_from(source)` 按来源增撤;`data/attributes.json` + 设计器「属性」页。**接线三条死数据**:`spell_evaluator` MAX_OPS 改读生效 `cpu_limit`(法杖份额以 `source="core"` 加成接入,运行时实测 3→120 / 5→200 / 8→320 步)、`player_manager``const MOVE_SPEED` 改读 `PlayerStats.move_speed`(**发版基准仍是 200**;实测手段是临时挂一条 `+50%` 加成使生效值变 300,测得玩家实际位移速度同步由 200 变 300 px/s,证明读取端是活的)、`_handle_auto_cast` 开火时乘 `cast_delay_mod`。删孤儿字段 `armor`/`resistance``hp_max`/`cpu_limit` 移出存档(改为派生值)
- ⚠️ **实际范围小于原描述**:原写「定义 4/5 缺失 player stats(如暴击/急速/范围/吸血/减伤)」—— 那些属性名是拟稿推测,不存在于任何权威文档(见上方「现状」订正)。本期只做**框架 + 三条死数据**,明确延后三项,各有理由:
- `attunement_fire/ice/lightning/poison`(4 个元素精通)—— 属**新增玩法维度**而非断线,需接入元素伤害管线,独立立项。
- `luck` —— 权威表定义它「影响暴击率」,而**暴击链目前是死的**(`bullet_manager.gd:81``is_crit` 硬编码为 `false``CastStats.crit_chance` 从未被掷判);打通暴击链是其前置。
- `recharge_speed_mod` —— 代码中**不存在充能系统**(全项目 grep `recharge` 零命中),属新增机制而非断线。
- 另:`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` 段即可上架、零代码」只在该属性已接入 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。
@@ -163,7 +172,39 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置,
**依赖**:无。各项可独立作为"缝隙任务"穿插。
**切分(独立小项)**:① DamageNumber 飘字(S,高手感回报);② 敌人 LOD 屏内剔除 + 屏外低频分离(S~M);③ 内容名 tr 化 + 裸中文静态检查(S,i18n 收尾);④ UI 关键面板 `.tscn` 化(M,可选);⑤ C# 热路径落地(L,仅当 GDScript 达性能瓶颈时;当前 500 敌 3.6% 预算,非必需)。
**切分(独立小项)**:① DamageNumber 飘字(S,高手感回报);② 敌人 LOD 屏内剔除 + 屏外低频分离(S~M);③ 内容名 tr 化 + 裸中文静态检查(S,i18n 收尾);④ UI 关键面板 `.tscn` 化(M,可选);⑤ C# 热路径落地(L,仅当 GDScript 达性能瓶颈时;当前 500 敌 3.6% 预算,非必需);⑥ **计算模块化重构(M~L,2026-07-31 立项确认,用户指定)** —— 见下
### E7-⑥ 计算模块化重构(用户 2026-07-31 指定的架构方向)
**原则**:凡涉及计算的都由**专门功能模块**负责,便于测试、配置,符合「专门模块负责专门功能」的设计理念。`AttributeFormula`E3-① 建)是已验证的模板:`class_name` 纯静态、零依赖(不碰 autoload/场景树),因此**不启动游戏就能单元断言**,改公式立刻回归。
**现状盘点(代码复核 2026-07-31)**:计算散落 8 处,仅 1 处已模块化。
| 计算 | 现在住在哪 | 状态 |
| :-- | :-- | :-- |
| 属性合成 | `AttributeFormula` | ✅ 已模块化(模板) |
| 伤害合成(一半) | `DamageContext.calc_damage``damage_context.gd:26`) | ⚠️ 与上下文数据混在同一个类 |
| 伤害合成(另一半) | `EnemyManager.apply_damage:285-289` | ❌ 内联在管理器 |
| 经验曲线 | `PlayerStats.xp_for_level` | 纯静态但与状态混住 |
| 分数 | `EndlessRecords.compute_score` | 同上 |
| 刷新费用 | `ShopManager.get_reroll_cost` | ❌ 内联 |
| 蓝耗 + heat 乘子 | `SpellEvaluator._charge_mana` | ❌ 内联在法术 VM |
| 波次缩放 | `WaveManager:61,62,76` | ❌ 内联三处 |
**最严重的一处 —— 伤害被劈成两半,分别在两个文件里,且两处都在扣护甲**:
```gdscript
# damage_context.gd:26
base_damage × mult × (1 resistance) armor × (1 pierce_rate)
# enemy_manager.gd:285-289 ← 又算了一遍
damage × (1 + combo_stacks × 0.02) armor # 保底 1
```
目前**没有**重复扣,因为 `apply_damage_from_context` 特意传 `calc_damage(0.0, res)` 把 armor 置 0 留给下游 —— 但该约定只存在于调用点,两个函数各自看都是完整公式。想知道「这一击到底怎么算」必须同时读两个文件并知道那条不成文约定。连击的 `+2%/层` 也硬编码在管理器里,不在任何数据文件中。
**立项时必须先决定的设计问题**(都不该在实现期顺手定):伤害那两半如何合并、`calc_damage``armor` 参数语义要不要改、连击 `+2%` 进哪个数据文件、DOT 伤害(`status_manager.gd:116` 直调 `apply_damage`,绕过 context 故不享元素抗性,E1-① 记录的已知限制)是否借此一并归位。
**风险**:触碰全游戏最热路径(500 敌 + 1500 弹),必须有迁移前后的基准对比。
**依赖**:无硬依赖。`PriceFormula`(E3-② 货架 C 建)会成为第二个已模块化的例子,可与 `AttributeFormula` 一起确立命名/签名/测试惯例,再据此重构其余 7 处。
**验收标准**:飘字随命中显示;屏外敌人低频更新且 FPS 提升;4 语言无裸中文;MCP/截图实测。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -70,7 +70,7 @@
| :-- | --: | --: | --: | :-- | :-- |
| `cpu_limit` | **0** | 20 | 50 | `add_int` | 修死数据;生效 MAX_OPS 用 `玩家 + 法杖` |
| `move_speed` | **200** | 600 | 800 | `hybrid` | 由 `const` 转为属性 |
| `cast_delay_mod` | 1.0 | 0.1 | 0.01 | `inverse` | 新增,乘到 `core.cast_interval` |
| `cast_delay_mod` | 1.0 | 0.1 | **0.05** | `inverse` | 新增,在**开火时**乘到 `core.cast_interval``hard` 原定 0.012026-07-31 据实测上调至 0.05,见 §2.2b |
| `hp_max` | 100 | 2000 | — | `hybrid` | 已可用,迁入框架 |
**`cpu_limit` 玩家基准取 0**(权威表写 5):因生效值 = 玩家 + 法杖,而法杖已提供 3–8。取 0 使小木法杖仍为 5 → MAX_OPS 200,**与当前行为完全一致,零平衡改动**;取 5 会让所有法杖的执行预算翻倍。语义上玩家属性是「全局加成」(权威 §1.1 开头:「这些属性是全局的,会修正所有法杖的输出」),基准为 0 正是纯加成语义。
@@ -91,6 +91,125 @@ PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core")
**`mana_max` 本期不迁入**:权威 §1.1 写明「法杖也有自己的上限,取 Min 值」,而现状是核心值直接覆盖玩家值。那个 Min 语义需要独立的设计判断(取 Min 后玩家升级蓝上限在低上限法杖下完全无效,这是否是设计意图?),不应混进属性框架这一期顺手改。本期保持原样,记录为已知遗留。
### 2.2b `cast_delay_mod` 的饱和点 —— 实测数据与 `hard` 上调(2026-07-31 追记)
> 本节记录的是**运行时实测**,不是推导。数据来自验收期(Task 5 Step 7b②)向 `get_tree().root`
> 挂载的临时 `_physics_process` 扫描节点:16 个相位 × 180 物理帧,逐相位切换法杖与
> `cast_delay_mod`,记录**相邻两次施法的帧距分布**。
#### 现象
`player_manager._handle_auto_cast``if` 而非 `while`
```gdscript
_cast_timer -= delta
if _cast_timer <= 0.0:
_cast_timer = _cast_interval * PlayerStats.cast_delay_mod # 赋值,非 -= 或 +=
SpellEvaluator.execute_compiled(...)
```
因此**每物理帧至多施法一次**,60 Hz 物理步长下硬顶 60 次/秒。且 `_cast_timer` 是**赋值**而非累积扣减,
亚帧余量每周期被丢弃 —— 实际射速按**整帧量化**。
#### 实测表
每个相位的帧距分布都是**单值**(如「1 帧×179」「2 帧×89」),这本身就是量化的直接证据。
| 法杖 | `cast_delay_mod` | 生效间隔 T | 实测帧距 | 实际速率 |
| :-- | --: | --: | --: | --: |
| `wand_basic`(基准 0.5 s | 1.0 | 0.5 s | 31 帧 | 1.94 /s |
| | 0.5 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1soft** | 0.05 s | 3 帧 | **20 /s** |
| | 0.07 | 0.035 s | 3 帧 | 20 /s |
| | 0.06 | 0.03 s | 2 帧 | 30 /s |
| | **0.05(现 hard** | 0.025 s | 2 帧 | **30 /s** |
| | 0.04 | 0.02 s | 2 帧 | 30 /s |
| | 0.034 | 0.017 s | 2 帧 | 30 /s |
| | **0.033** | 0.0165 s | 1 帧 | **60 /s ← 饱和点** |
| | 0.02 | 0.01 s | 1 帧 | 60 /s |
| | 0.01(原 hard | 0.005 s | 1 帧 | 60 /s |
| `wand_fast`(基准 0.25 s | 1.0 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1soft** | 0.025 s | 2 帧 | **30 /s** |
| | 0.07 | 0.0175 s | 2 帧 | 30 /s |
| | 0.0667 | 0.016675 s | 2 帧 | 30 /s |
| | **0.05(现 hard** | 0.0125 s | 1 帧 | **60 /s ← 已在饱和区内(饱和点是 0.0667,见下)** |
| | 0.01(原 hard | 0.0025 s | 1 帧 | 60 /s |
#### 四条结论
1. **饱和点 = `(1/60) / 法杖基准间隔`**
| 法杖 | 基准间隔 | 饱和点 | 来源 |
| :-- | --: | --: | :-- |
| `wand_basic` | 0.5 s | ≈ **0.0333** | 实测(0.034 仍 2 帧、0.033 已 1 帧)|
| `wand_fast` | 0.25 s | ≈ **0.0667**= 1/15| 实测夹在 (0.05, 0.0667] 内:0.0667 仍 2 帧、0.05 已 1 帧 |
| `circuit_fork` | 0.6 s | ≈ 0.0278 | **公式推导,未实测** |
> **关于 `wand_fast @ 0.0667` 读到 2 帧**:数据没错,是采样点的问题。真实边界是 `1/15 = 0.06666…`
> 我取的 `0.0667` 比它高 `3.3e-5`T 上高 `8.3e-6 s`),故落在 2 帧那侧。再叠加结论 3 的浮点余量效应
> —— T 恰为整帧倍数时反而多花一帧 —— **实际的 1 帧上界略低于 0.0667**。故 `wand_fast` 的饱和点
> 应读作「0.0667 稍下方」,本文一律以公式值 0.0667 记,误差方向是保守的(真实无效区间只会**更大**一点)。
2. **soft→hard 之间的可达档位是台阶而非曲线**`wand_basic` 只有 20 / 30 / 60 次每秒三档,
`wand_fast` 只有 30 / 60 两档。中间值买不到。
3. **周期恒为 `ceil(T × 60)` 帧,且 T 恰为整帧倍数时会多花一帧**`T = 0.5`**31** 帧而非 30
`T = 0.25`**16** 帧而非 15):`_cast_timer` 是赋值,`0.5 - 30 × delta` 的浮点余量为正,
要到第 31 帧才跨过 0。故**实际射速恒 ≤ 名义值,永不超发** —— 这是好事,但意味着名义
「2 次/秒」实际是 1.94 次/秒。
4. 综上,原 `hard: 0.01` 在两把主力杖上都**深深落在饱和区内**:`wand_basic` 白费 3.3 倍区间、
`wand_fast` 白费 6.7 倍。玩家在货架 C 花钱把 `cast_delay_mod` 从 0.033 买到 0.01**射速一点没变**。
#### 决定(用户 2026-07-31
`hard: 0.01 → 0.05`
**关键前提:饱和点 = `(1/60) / 法杖基准间隔`,随杖而异,因此不存在对所有杖都最优的单一 `hard`。**
`hard` 是**全局**的一个数,而每把杖的饱和点各不相同(0.0278 / 0.0333 / 0.0667),任何取值都只能覆盖一部分:
| `hard` 取值 | `circuit_fork`(0.0278) | `wand_basic`(0.0333) | `wand_fast`(0.0667) |
| :-- | :-- | :-- | :-- |
| 0.01(原值) | 无效区间 0.0278→0.01 | 无效区间 0.0333→0.01 | 无效区间 0.0667→0.016.7 倍)|
| **0.05(已采用)** | ✅ 无无效区间 | ✅ 无无效区间 | ⚠️ 仍剩 `[0.05, 0.0667)` |
| 0.0667 | ✅ | ✅ | ✅ 无无效区间 |
—— 但 `0.0667` 会让 `wand_basic`(饱和 0.0333)与 `circuit_fork`(0.0278)**够不到自己的饱和点**,
即它们的最高射速被硬上限提前截断,**制造出新的"不可达区间"**(想更快但买不到)。
**故 0.05 是折中**:让**多数杖**`wand_basic` / `circuit_fork`,以及所有基准间隔 ≥ 0.333 s 的杖)
无无效区间,只在最快的 `wand_fast` 上留 `[0.05, 0.0667)` 这一小段。相对原 `0.01`
(两把主力杖都深陷饱和区)是大幅改进,但**不是"两把主力杖都不再有无效区间"** ——
`wand_fast` 那段仍在,只是从 6.7 倍缩到 1.33 倍。
> **📌 订正记录(2026-07-31)**:本节初稿曾写「`wand_fast` 恰好在 0.05 饱和 / 两把主力杖都无无效区间」,
> **算错了** —— 按同一节自己给出的公式 `(1/60)/0.25 = 0.0667`,不是 0.05。实测数据表(§2.2b 上方)
> 记的 0.0667 一直是对的,错的只是从那句口头转述派生出来的几处标注。已连同
> `numerical_design.md` §1.1、roadmap E3-①、plan Step 7b② 一并订正。
> **教训与本期其余三条同源**:结论若与自己上一句的公式矛盾,是最容易被略过的一类错 ——
> 因为读者会假定作者算过。
**明确不做**:不在代码里加下限钳制。下限已由 `attributes.json` + `AttributeFormula`
`maxf(hard, ...)` 强制;在 `player_manager` 再加一个会形成**重复权威**,设计师调 `hard` 时立刻脱同步,
违反 CLAUDE.md「纯数据驱动,代码不含硬编码副本或回退」。
#### 改动后的边界复测(`hard = 0.05` 落地后,同一套扫描)
| 法杖 | 请求 `mod` | 生效 `mod` | 生效间隔 T | 实测帧距 | 实际速率 | 判读 |
| :-- | --: | --: | --: | --: | --: | :-- |
| `wand_fast` | 0.05 | 0.05 | 0.0125 s | **1 帧×179** | 60 /s | ⚠️ 已达 60 /s,但饱和点是 0.0667 —— `[0.05, 0.0667)` 这段买了没用 |
| `wand_fast` | 0.01 | **0.05** | 0.0125 s | 1 帧×179 | 60 /s | ✅ `maxf(hard, …)` 生效,越界请求被钳回 |
| `wand_basic` | 0.05 | 0.05 | 0.025 s | **2 帧×89** | 30 /s | ✅ |
| `wand_basic` | 0.01 | **0.05** | 0.025 s | 2 帧×89 | 30 /s | ✅ 钳制生效 |
| `wand_basic` | 0.1soft | 0.1 | 0.05 s | 3 帧×59 | 20 /s | ✅ 对照,soft 段未受影响 |
| `circuit_fork` | 0.05 | 0.05 | 0.03 s | **2 帧×89** | 30 /s | ✅ 见下方遗留 |
> **遗留(实测)**`circuit_fork`0.6 s)在新 `hard` 0.05 处实测为 **2 帧/次(30 /s** ——
> 它的饱和点 0.0278(公式推导)低于 0.05,故**没有**无效区间,但也吃不满 60 /s;`wand_basic`
> 同理(0.0333 < 0.0530 /s)。反过来 `wand_fast`0.0667 > 0.05)能吃满 60 /s,代价是
> `[0.05, 0.0667)` 那段无效区间。
>
> **这正是"单一全局 `hard` 撞上逐杖饱和点"的必然取舍**`hard` 偏小则慢杖有无效区间,偏大则
> 快杖被截断。0.05 选择了**让无效区间只留在一把杖上**,因为无效区间是"花了钱没效果"(欺骗),
> 而吃不满硬顶只是"上限没那么高"(诚实)。若日后要彻底消除,正确做法是让 `hard` 逐杖化
> (即把它变成 `cores.json` 的一个字段),而不是继续挪这个全局数 —— 那属于新的设计立项。
### 2.3 公式模块 —— `AttributeFormula`
**所有加成公式集中在一个专门模块**,与状态持有分离。
@@ -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 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`
```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 | 1217 次 |
**`cpu_limit` 必须 `flat`** —— `AttributeFormula``add_int` 明确拒绝 `pct``push_error`(乘算对离散指令预算无意义)。这条硬约束正是「增量必须逐属性配置」的原因,不是偏好。
首购 60–120G 与最便宜的法术(50G)同档,形成真实竞争(每波收入约 150–250G:击杀 15G + 波次结算 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 = <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)
```
**每属性至多一条 `_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/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/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` 一起确立命名/签名/测试惯例。
+88
View File
@@ -41,6 +41,12 @@ var _core_btn: Button = null
var _inv_btn: Button = null
var _lang_btn: Button = null
## 属性购买(货架 C):独立子面板,由商店面板上的入口按钮打开
## (商店面板自身已无空间容纳四行属性——面板 y=200~540,既有控件已占到 y≈522,
## 仅剩约 18px,故不内联在商店面板里,改开独立弹窗,参照 _setup_settings_ui 的结构)
var _attr_layer: CanvasLayer = null
var _attr_rows: Array = [] # [{id: String, label: Label, button: Button}]
## 背包(插槽编辑)
var _inv_layer: CanvasLayer = null
var _inv_root: Control = null
@@ -71,6 +77,7 @@ func _ready() -> void:
_setup_world_view()
_setup_hud()
_setup_shop_ui()
_setup_attr_shop_ui()
_setup_inventory_ui()
_setup_settlement_ui()
_setup_settings_ui()
@@ -84,6 +91,8 @@ func _input(event: InputEvent) -> void:
_close_inventory()
elif _settings_layer and _settings_layer.visible:
_close_settings()
elif _attr_layer and _attr_layer.visible:
_close_attr_shop()
elif _settle_layer and _settle_layer.visible:
pass # 结算屏:ESC 无效,必须按按钮
elif _shop_layer and _shop_layer.visible:
@@ -343,6 +352,15 @@ func _setup_shop_ui() -> void:
settings_btn.connect("pressed", _open_settings)
_shop_layer.add_child(settings_btn)
# 属性购买入口(货架 C):面板 y=445 行内剩余空白(settings_btn 右边到面板边缘 x=610~800),
# 不改动本函数内任何既有控件的坐标;点击打开独立子面板 _attr_layer(见 _setup_attr_shop_ui
var attr_btn := Button.new()
attr_btn.text = tr("SHOP_ATTR_BTN")
attr_btn.position = Vector2(630, 445)
attr_btn.size = Vector2(150, 34)
attr_btn.connect("pressed", _open_attr_shop)
_shop_layer.add_child(attr_btn)
_close_btn = Button.new()
_close_btn.text = tr("SHOP_NEXT_WAVE")
_close_btn.position = Vector2(550, 400)
@@ -356,6 +374,67 @@ func _setup_shop_ui() -> void:
_shop_warn.visible = false
_shop_layer.add_child(_shop_warn)
# ── 属性购买(货架 C)子面板 ────────────────────────────────
## 完全数据驱动:行数与内容全部来自 ShopManager.get_sellable_attrs()/get_attr_def()
## 本函数不含任何属性 id 的硬编码分支;新增可售属性只需改 data/attributes.json 的 shop 段
func _setup_attr_shop_ui() -> void:
_attr_layer = CanvasLayer.new()
_attr_layer.name = "AttrShopLayer"
_attr_layer.visible = false
add_child(_attr_layer)
var bg := ColorRect.new()
bg.color = Color(0.06, 0.06, 0.12, 0.97)
bg.size = Vector2(560, 320)
bg.position = Vector2(300, 160)
_attr_layer.add_child(bg)
var title := Label.new()
title.text = tr("SHOP_ATTR_TITLE")
title.position = Vector2(324, 178)
title.add_theme_font_size_override("font_size", 20)
title.modulate = Color.GOLD
_attr_layer.add_child(title)
_attr_rows.clear()
var ids: Array[String] = ShopManager.get_sellable_attrs()
# 行容量上限(当前坐标常量下):行 i 占 y=[220+34i, 250+34i]label/button 高 30),
# 关闭按钮占 y=[420, 460](见下方 close.position/size)。250+34i <= 420 ⟺ i <= 5
# 即最多 6 行(i=0..5)不与关闭按钮重叠;第 7 行(i=6)落在 y=424,正压在按钮上。
# 路线图 E3-② 延后的 7 个属性一旦全部上架会超出此容量,需要先做布局/滚动改造——
# 本轮评审 Finding 4 仅要求记录容量,不在此实现滚动,故不改动下方坐标。
for i in ids.size():
var id: String = ids[i]
var y: float = 220.0 + float(i) * 34.0
var lbl := Label.new()
lbl.position = Vector2(324, y)
lbl.size = Vector2(300, 30)
_attr_layer.add_child(lbl)
var btn := Button.new()
btn.position = Vector2(640, y)
btn.size = Vector2(180, 30)
btn.connect("pressed", _on_buy_attr_pressed.bind(id))
_attr_layer.add_child(btn)
_attr_rows.append({"id": id, "label": lbl, "button": btn})
var close := Button.new()
close.text = tr("SHOP_ATTR_CLOSE")
close.position = Vector2(700, 420)
close.size = Vector2(120, 40)
close.connect("pressed", _close_attr_shop)
_attr_layer.add_child(close)
func _open_attr_shop() -> void:
_attr_layer.visible = true
_refresh_shop_ui()
func _close_attr_shop() -> void:
_attr_layer.visible = false
func _on_buy_attr_pressed(id: String) -> void:
if ShopManager.buy_attribute(id):
_refresh_shop_ui()
# ── HUD 刷新 ────────────────────────────────────────────────
func _refresh_hud() -> void:
@@ -403,6 +482,15 @@ func _refresh_shop_ui() -> void:
_core_btn.text = tr("SHOP_CORE_BTN") % _cm.get_core_display()
if _lang_btn:
_lang_btn.text = "🌐 " + Locale.get_locale()
for row in _attr_rows:
var id: String = row["id"]
var reason: String = ShopManager.can_buy_attribute(id)
var price: int = ShopManager.get_attr_price(id)
var n: int = ShopManager.get_attr_purchases(id)
var disp: String = String(ShopManager.get_attr_def(id).get("display_name", id))
row["label"].text = tr("SHOP_ATTR_ROW") % [disp, "%.2f" % PlayerStats.get_attr_value(id), n]
row["button"].text = (tr("SHOP_ATTR_BUY") % price) if reason.is_empty() else reason
row["button"].disabled = not reason.is_empty()
# ── 事件 ─────────────────────────────────────────────────
+4 -2
View File
@@ -15,8 +15,10 @@ func _ready() -> void:
# 装备法杯:wand_basic + spark_bolt
var loadout: Dictionary = WandPreset.make_default_loadout()
# 本场景绕过 CombatManager,故须自行注入法杖运算力份额(否则 cpu_limit=0 → MAX_OPS 退化)
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(loadout["core"].cpu_limit), "core")
# 顺序理由见 combat_manager._rebuild_wandremove 必须先于 add(否则重复进入会累积),
# 而两者又必须都先于 equip_wandequip_wand 里的 cpu_limit<=0 守卫读的就是注入结果)
PlayerStats.remove_modifiers_from(PlayerStats.MOD_SOURCE_CORE)
PlayerStats.add_modifier("cpu_limit", "flat", float(loadout["core"].cpu_limit), PlayerStats.MOD_SOURCE_CORE)
PlayerManager.equip_wand(loadout["core"], loadout["compiled"])
_run_spawn()
+37
View File
@@ -8,6 +8,12 @@ signal leveled_up(new_level: int)
# ── 数据源 ────────────────────────────────────────────
const ATTRIBUTES_JSON: String = "res://data/attributes.json"
## 法杖运算力份额的加成来源标识(combat_manager._rebuild_wand / combat_test 注入时使用)
## 必须用常量而非裸字面量:"core" 在 remove/add 两侧必须逐字相同——remove 那侧打错一个字符,
## 旧份额就不会被撤销而是逐次累积,且 cpu_limit 的 hard(50) 会把累积值封成一个看起来合理的
## 数字(不是显眼的荒谬值),故障零诊断且极难发现。常量把这个风险变成编译期错误。
const MOD_SOURCE_CORE: String = "core"
# ── HP ───────────────────────────────────────────────
var hp: float = 100.0
var hp_max: float = 100.0
@@ -31,6 +37,7 @@ var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now
# 属性定义与加成来源(非热路径)
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}]
var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取
# ── 魔力(MVP)─────────────────────────────────────────
var mana: float = 100.0
@@ -227,11 +234,41 @@ func _recompute_attrs() -> void:
cast_delay_mod = _compute_attr("cast_delay_mod")
hp_max = _compute_attr("hp_max")
hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > hp_max
# 冷路径通用视图:热路径(move_speed 每物理帧)仍走裸字段,此表只服务商店/UI 等按 id 取值的场景。
# 必须与裸字段在同一处更新——两者不同步会让商店显示的值与实际生效值不一致且无诊断。
_attr_effective = {
"cpu_limit": float(cpu_limit),
"move_speed": move_speed,
"cast_delay_mod": cast_delay_mod,
"hp_max": hp_max,
}
stats_changed.emit()
func _compute_attr(attr_id: String) -> float:
var d: Dictionary = _attr_def.get(attr_id, {})
if d.is_empty():
# 返回 0.0 的后果按属性而异,但都是「莫名其妙的一块砖」:缺 cast_delay_mod → 生效间隔 0
# → 每物理帧施法一次;缺 move_speed → 玩家不能动;缺 hp_max → 进场即死。
# Task 4 已硬化生产侧(attribute_tab 的 _loaded 拒绝写出零载荷),这里是消费侧对称的那一半。
# assert 与 _recompute_attrs 空定义分支同款:运行时 push_error 到不了任何日志通道
#(已实测,见 plan 工具坑 ⑧),只靠它等于没有诊断;assert 在 release 被编译掉、开发期立刻中断。
push_error("PlayerStats: attributes.json 缺少属性「%s" % attr_id)
assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id)
return 0.0
return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)
## 按 id 取生效值(冷路径)。热路径请直接读裸字段(move_speed 等),本函数有字典查找开销。
func get_attr_value(attr_id: String) -> float:
if not _attr_effective.has(attr_id):
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)
+8 -1
View File
@@ -7,7 +7,7 @@
## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔
extends Node
const SCHEMA_VERSION: int = 2 # v2:新增 wandCore/deck/bench)持久化
const SCHEMA_VERSION: int = 3 # v3:新增 attr_purchases(货架 C 属性购买次数)
const PATH_A: String = "user://run_a.json"
const PATH_B: String = "user://run_b.json"
@@ -63,6 +63,9 @@ func load_run() -> Dictionary:
func apply_run(data: Dictionary) -> void:
if data.is_empty():
return
# 必须早于 load_save_data:后者设 hp,而 _apply_attr_purchases 会经 _recompute_attrs
# 触发 hp = minf(hp, hp_max)。顺序颠倒会拿未加成的 hp_max 去钳,静默吞血。
ShopManager.apply_attr_purchases_save(data.get("attr_purchases", {}))
PlayerStats.load_save_data(data.get("player_stats", {}))
WaveManager.current_wave = int(data.get("wave_num", 0))
var shop_seed: int = int(data.get("shop_seed", 0))
@@ -90,6 +93,7 @@ func _collect_run_data() -> Dictionary:
"wave_num": WaveManager.current_wave,
"shop_seed": ShopManager.get_shop_seed(),
"player_stats": PlayerStats.get_save_data(),
"attr_purchases": ShopManager.get_attr_purchases_save(),
}
if is_instance_valid(_wand_provider) and _wand_provider.has_method("get_wand_save_data"):
d["wand"] = _wand_provider.get_wand_save_data()
@@ -115,4 +119,7 @@ func _migrate_run(data: Dictionary) -> Dictionary:
if ver < 2:
# v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段
data["schema_version"] = 2
if ver < 3:
# v2→v3: 旧档无 attr_purchasesapply_run 的 data.get(..., {}) 已处理缺失,无需补字段
data["schema_version"] = 3
return data
+155
View File
@@ -10,12 +10,20 @@ const SLOT_COUNT: int = 3
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
const REROLL_STEP: int = 10 # 每次递增价格
## 货架 C 加成来源标识。必须用常量而非裸字面量:来源串在 remove/add 两侧必须逐字相同——
## remove 那侧打错一个字符,旧份额就不会被撤销而是逐次累积(理由同 PlayerStats.MOD_SOURCE_CORE
const MOD_SOURCE_SHOP_C: String = "shop_c"
const ATTRIBUTES_JSON: String = "res://data/attributes.json"
var current_slots: Array = [] # Array[SpellNode] null 表示已售出)
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
var _attr_purchases: Dictionary = {} # attr_id(String) → 已购次数(int);唯一写入点 _apply_attr_purchases()
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
func _ready() -> void:
_load_attr_definitions()
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
func _on_wave_complete(_payload: Dictionary) -> void:
@@ -86,7 +94,154 @@ func close_shop() -> void:
func get_shop_seed() -> int:
return _shop_seed
# ── 货架 C:属性购买 ──────────────────────────────────────
func _load_attr_definitions() -> void:
if not FileAccess.file_exists(ATTRIBUTES_JSON):
push_error("ShopManager: 缺少 %s" % ATTRIBUTES_JSON)
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
if not (parsed is Dictionary):
push_error("ShopManager: %s 格式错误(应为对象)" % ATTRIBUTES_JSON)
return
for k in parsed:
if not (parsed[k] is Dictionary):
push_error("ShopManager: attributes.json 的「%s」应为对象,整个文件已拒绝加载" % k)
return
_attr_def = parsed
## 可售属性 = 带 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 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))
func get_attr_price(attr_id: String) -> int:
var sp = _attr_def.get(attr_id, {}).get("shop", null)
if not (sp is Dictionary):
return 0
return PriceFormula.compute(sp, get_attr_purchases(attr_id))
## 返回 "" 表示可买;否则为禁用原因(UI 直接显示,不要只灰掉按钮)
## ⚠️ i18n 债务:以下禁用原因是中文裸串,未经 tr(),不随语言切换——这与法术/核心/状态等
## display_name 现状一致,本期不制造新例外;统一处理见路线图 E7-③「内容名 tr 化」
func can_buy_attribute(attr_id: String) -> String:
var d: Dictionary = _attr_def.get(attr_id, {})
var sp = d.get("shop", null)
if not (sp is Dictionary):
return "该属性不可购买"
# 与 get_sellable_attrs 同一条不变量,须在此再判一次:UI 只列可售项,但本函数是购买路径的
# 独立守门人(货架 B / 出售退款等后来者会直接调它,未必先过 get_sellable_attrs)。
# 缺这条则未接线属性可被买成空气,且 _at_soft_cap 因生效值恒为 0 永不封顶 → 可无限购买
if not PlayerStats.has_attr(attr_id):
return "该属性不可购买"
if _at_soft_cap(attr_id, d):
return "已达上限"
if PlayerStats.gold < get_attr_price(attr_id):
return "金币不足"
return ""
## 已达 soft 上限?soft 是「常规来源可达上限」(E3-① 保留该字段正为此)
## inverse 属性越低越好,故方向相反
func _at_soft_cap(attr_id: String, d: Dictionary) -> bool:
var soft: float = float(d.get("soft", 0.0))
if soft <= 0.0:
return false
var cur: float = PlayerStats.get_attr_value(attr_id)
if String(d.get("combine", "hybrid")) == "inverse":
return cur <= soft
return cur >= soft
## 属性定义(供 UI 读 display_name 等,避免在 UI 侧再复制一份属性名表)
func get_attr_def(attr_id: String) -> Dictionary:
var d = _attr_def.get(attr_id, {})
return d if d is Dictionary else {}
func buy_attribute(attr_id: String) -> bool:
var reason: String = can_buy_attribute(attr_id)
if not reason.is_empty():
return false
var cost: int = get_attr_price(attr_id)
if not PlayerStats.spend_gold(cost):
return false
_attr_purchases[attr_id] = get_attr_purchases(attr_id) + 1
_apply_attr_purchases()
shop_refreshed.emit()
return true
## 唯一写入点:所有改动 _attr_purchases 的路径(购买 / 出售退款 / 存档回读 / reset)
## 都必须经此重建加成。两份状态不同步会产生「显示买了 3 次但加成只有 2 次」且无任何诊断
func _apply_attr_purchases() -> void:
PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
for id in _attr_purchases:
var n: int = int(_attr_purchases[id])
if n <= 0:
continue
var sp = _attr_def.get(id, {}).get("shop", null)
if not (sp is Dictionary):
push_error("ShopManager: 「%s」有购买记录但无 shop 段,加成已跳过" % id)
continue
var mode: String = String(sp.get("mode", "flat"))
var step: float = float(sp.get("step", 0.0))
# pct 合并必须按属性的 combine 分支——AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1-v
# 故两者的等价单条值不同。写错会让 inverse 属性(cast_delay_mod)的曲线整体偏离且零诊断。
var combine: String = String(_attr_def.get(id, {}).get("combine", "hybrid"))
var merged: float = 0.0
if mode == "pct":
# hybrid: 需 f=(1+step)^n → merged=(1+step)^n1
# inverse: 需 f=(1step)^n → merged=1(1step)^n
merged = (1.0 - pow(1.0 - step, float(n))) if combine == "inverse" else (pow(1.0 + step, float(n)) - 1.0)
else:
merged = step * float(n) # flat 线性可加,与 combine 无关
PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C)
## 存档:只存次数,加成是派生物(回读时经 _apply_attr_purchases 重建)
func get_attr_purchases_save() -> Dictionary:
return _attr_purchases.duplicate()
func apply_attr_purchases_save(d: Dictionary) -> void:
_attr_purchases.clear()
for k in d:
_attr_purchases[String(k)] = int(d[k])
_apply_attr_purchases()
func reset() -> void:
current_slots.clear()
reroll_count = 0
_shop_seed = 0
_attr_purchases.clear()
_apply_attr_purchases() # 撤销 shop_c 加成(PlayerStats.reset_for_run 已清 _modifiers,此处保证独立调用时也正确)
+7 -2
View File
@@ -39,6 +39,11 @@ func _ready() -> void:
## 开始新局
func start_game() -> void:
PlayerStats.reset_for_run()
# ShopManager.reset() 必须晚于 reset_for_run():它会撤销 shop_c 加成(remove_modifiers_from
# 经 _apply_attr_purchases),若先于 reset_for_run 调用,reset_for_run 清空 _modifiers 时
# 不会同步清 _attr_purchases,两份状态就此错开——新的一局会显示「已购 N 次」却生效值是基准值,
# 且下次购买会把整份旧 _attr_purchases 重新套用回 PlayerStats(复现过程见本轮评审 Finding 1)。
ShopManager.reset()
WaveManager.reset()
BulletManager.reset()
EnemyManager.reset()
@@ -272,8 +277,8 @@ func _rebuild_wand() -> void:
# 法杖运算力以加成来源接入(非调用点相加),使 hard 上限作用于生效总值
# remove 必须在 add 之前:本函数每次 install_spell 都会跑,漏了 remove 会让 cpu_limit
# 5→10→15 地累积,而 hard: 50 会把它封成一个看起来合理的数字,不是显眼的异常值
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(_equipped_core.cpu_limit) if _equipped_core else 0.0, "core")
PlayerStats.remove_modifiers_from(PlayerStats.MOD_SOURCE_CORE)
PlayerStats.add_modifier("cpu_limit", "flat", float(_equipped_core.cpu_limit) if _equipped_core else 0.0, PlayerStats.MOD_SOURCE_CORE)
# ── 法杖存档(ProfileManager A/B 槽,ADR-A2)─────────────────
func get_wand_save_data() -> Dictionary:
+2 -1
View File
@@ -93,7 +93,8 @@ func equip_wand(core: CoreDefinition, compiled: CompiledDeck) -> void:
PlayerStats.mana_heat = 0.0
# 0 守卫:cpu_limit=0 → MAX_OPS 退化,法术近乎全静默。放在换杖处而非施法处——施法端 2 次/秒
# 会刷屏埋掉根因,且此处拿得到杖名。兜的是「新调用点忘了注入」与「cores.json 某杖配成 0」,
# 二者上游无任何报错(缺 attributes.json / attr_id 打错已分别由 player_stats.gd:173/191 报出)
# 二者上游无任何报错(缺 attributes.json / attr_id 打错已分别由 PlayerStats._load_attr_definitions
# 与 PlayerStats.add_modifier 报出)
if PlayerStats.cpu_limit <= 0:
push_error("PlayerManager: 装备「%s」后 cpu_limit=0,法术执行预算将退化为 40 步;检查该杖 cores.json 的 cpu_limit 与 source=\"core\" 加成注入" % (core.display_name if core else ""))
+55
View File
@@ -0,0 +1,55 @@
## PriceFormula — 价格计算的唯一实现处
## 纯静态函数:无状态、不依赖 ShopManager / PlayerStats / 场景树,故可脱离游戏进程单元测试
## 本模块**不认识**「属性/武器/装备」,只认识 {price_base, price_growth, curve} ——
## 划分点在数据里,故货架 B(核心抽取)与子计划 ④(出售退款)可直接复用。
## 权威来源:docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md §2.1
class_name PriceFormula
extends RefCounted
## 命名为 PriceCurve 而非 CurveGodot 引擎自带全局类 `Curve`(曲线资源),
## 嵌套枚举若同名会被解析器拒绝("member Curve shadows a native class"),
## 与 class_name 是否注册无关,故不能叫 Curve。此为本任务对简报字面代码的
## 唯一必要偏离,详见 task-1-report.md。
enum PriceCurve { GEOMETRIC, LINEAR, FLAT }
const _CURVE_BY_NAME: Dictionary[String, PriceCurve] = {
"geometric": PriceCurve.GEOMETRIC,
"linear": PriceCurve.LINEAR,
"flat": PriceCurve.FLAT,
}
## JSON 的 curve 字符串 → 枚举;未知值 push_error 并回退 GEOMETRIC
static func curve_from_string(s: String) -> PriceCurve:
if _CURVE_BY_NAME.has(s):
return _CURVE_BY_NAME[s]
push_error("PriceFormula: 未知 curve「%s」,回退 geometric" % s)
return PriceCurve.GEOMETRIC
## 唯一的价格入口
## spec —— 数据文件里的定价段,读 price_base / price_growth / curve
## purchased —— 已购次数(0 = 首次购买)
## 返回 int(金币是整数),下限 1:免费购买无意义,且 0 价会让「买不起」的判定失效
static func compute(spec: Dictionary, purchased: int) -> int:
var base: float = float(spec.get("price_base", 0.0))
var growth: float = float(spec.get("price_growth", 1.0))
var n: int = maxi(purchased, 0)
var curve: PriceCurve = curve_from_string(String(spec.get("curve", "geometric")))
var raw: float = 0.0
match curve:
PriceCurve.LINEAR:
raw = base + float(n) * growth
PriceCurve.FLAT:
raw = base
_:
raw = base * pow(growth, float(n))
# raw 可能在两个不同的地方失控:pow() 把 double 本身推到 INFn 极大,如 geometric
# n≈5077+),或者 raw 仍是合法有限 double,但早已超出 int64 可安全表示的范围(如
# geometric n=300 时 raw≈9.7e19——finite 但 > INT64_MAX≈9.22e18)。后者 is_finite()
# 测不出来,roundi() 对超范围 float 的行为是未定义/环绕,曾亲测把它环绕成极小/负值,
# 再经 maxi(...,1) 静默塌陷成 1——方向与"买得越多越贵"完全相反。故用同一个安全阈值
# 9.0e15(远小于 INT64_MAX,远超任何合理金币量)同时兼答两种情况。
const _PRICE_CLAMP: float = 9.0e15
if not is_finite(raw) or raw > _PRICE_CLAMP:
push_error("PriceFormula: 价格溢出(base=%f growth=%f purchased=%d),已钳到上限" % [base, growth, n])
raw = _PRICE_CLAMP
return maxi(roundi(raw), 1)
+1
View File
@@ -0,0 +1 @@
uid://b4h1n0puafawv
@@ -331,6 +331,8 @@ func execute_compiled(compiled: CompiledDeck, caster_id: int, spawn_pos: Vector2
var ops_count: int = 0
# 生效 cpu_limit 已含法杖份额(combat_manager._rebuild_wand 以 source="core" 的加成接入)
# 下限 1:注入缺失时不把游戏打成砖(0 会让 while 一步都不走);该异常由 equip_wand 处报出
# 注:仅顶层执行循环。execute_sub 的子荷载预算是独立的 MAX_OPS_PER_CPU * 2= 80),
# 与 cpu_limit 无关——既有行为,改前同样脱节,本次接线未触及
var max_ops: int = MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)
var actions_remaining: int = 1 # 初始为 1,由 multicast_count 加成
while deck.has_next() and ops_count < max_ops:
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 Inventory"
msgid "SHOP_SOLD"
msgstr "(Sold)"
msgid "SHOP_ATTR_BTN"
msgstr "📊 Attributes"
msgid "SHOP_ATTR_TITLE"
msgstr "📊 Buy Attributes"
msgid "SHOP_ATTR_ROW"
msgstr "%s Current %s Bought %d×"
msgid "SHOP_ATTR_BUY"
msgstr "Buy %dG"
msgid "SHOP_ATTR_CLOSE"
msgstr "Close"
msgid "INV_TITLE"
msgstr "🎒 Inventory — [%s] %d slots (%s)"
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 バッグ"
msgid "SHOP_SOLD"
msgstr "(売却済)"
msgid "SHOP_ATTR_BTN"
msgstr "📊 属性"
msgid "SHOP_ATTR_TITLE"
msgstr "📊 属性購入"
msgid "SHOP_ATTR_ROW"
msgstr "%s 現在 %s 購入回数 %d"
msgid "SHOP_ATTR_BUY"
msgstr "購入 %dG"
msgid "SHOP_ATTR_CLOSE"
msgstr "閉じる"
msgid "INV_TITLE"
msgstr "🎒 バッグ — [%s] %d スロット (%s)"
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 背包"
msgid "SHOP_SOLD"
msgstr "(已售出)"
msgid "SHOP_ATTR_BTN"
msgstr "📊 属性"
msgid "SHOP_ATTR_TITLE"
msgstr "📊 属性购买"
msgid "SHOP_ATTR_ROW"
msgstr "%s 当前 %s 已购 %d 次"
msgid "SHOP_ATTR_BUY"
msgstr "购买 %dG"
msgid "SHOP_ATTR_CLOSE"
msgstr "关闭"
msgid "INV_TITLE"
msgstr "🎒 背包 — [%s] %d 槽 (%s)"
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 背包"
msgid "SHOP_SOLD"
msgstr "(已售出)"
msgid "SHOP_ATTR_BTN"
msgstr "📊 屬性"
msgid "SHOP_ATTR_TITLE"
msgstr "📊 屬性購買"
msgid "SHOP_ATTR_ROW"
msgstr "%s 目前 %s 已購 %d 次"
msgid "SHOP_ATTR_BUY"
msgstr "購買 %dG"
msgid "SHOP_ATTR_CLOSE"
msgstr "關閉"
msgid "INV_TITLE"
msgstr "🎒 背包 — [%s] %d 槽 (%s)"