Compare commits

..
25 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
18 changed files with 1813 additions and 10 deletions
+3
View File
@@ -25,3 +25,6 @@ gdextension/**/~*
gdextension/**/*.obj.import gdextension/**/*.obj.import
**/.sconsign.dblite **/.sconsign.dblite
compile_commands.json compile_commands.json
# superpowers 子代理工作区(账本/简报/评审包,git-ignored scratch
.superpowers/
+44 -1
View File
@@ -9,9 +9,14 @@ const PATH = "res://data/attributes.json"
const COMBINE_KEYS = ["hybrid", "inverse", "add_int"] const COMBINE_KEYS = ["hybrid", "inverse", "add_int"]
const COMBINE_LABELS = ["混合 hybrid", "反向 inverse", "整数加 add_int"] const COMBINE_LABELS = ["混合 hybrid", "反向 inverse", "整数加 add_int"]
const ATTR_ORDER = ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"] 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 _data: Dictionary = {}
var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton} 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 _status: Label
var _loaded: bool = false # 加载失败时禁止写回,见 _save var _loaded: bool = false # 加载失败时禁止写回,见 _save
@@ -49,8 +54,28 @@ func _ready() -> void:
grid.add_child(sp_base); grid.add_child(sp_soft); grid.add_child(sp_hard); grid.add_child(op) 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} _rows[attr_id] = {"base": sp_base, "soft": sp_soft, "hard": sp_hard, "combine": op}
add_child(HSeparator.new()) 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() 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 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句) # dock 的 ScrollContainer 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句)
tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
tip.modulate = Color(0.7, 0.7, 0.7) tip.modulate = Color(0.7, 0.7, 0.7)
@@ -99,6 +124,24 @@ func _collect() -> Dictionary:
entry["hard"] = _clean(r["hard"].value) entry["hard"] = _clean(r["hard"].value)
# 存 key,不存双语显示串;selected 恒为 0..2UI.opt 构造时已 clampi,本页此后不再赋值) # 存 key,不存双语显示串;selected 恒为 0..2UI.opt 构造时已 clampi,本页此后不再赋值)
entry["combine"] = COMBINE_KEYS[r["combine"].selected] 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 out[attr_id] = entry
return out return out
+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" }, "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" }, "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" }, "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" } "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" } }
} }
+7 -2
View File
@@ -18,7 +18,7 @@
| `hp_max` | 最大生命 | 100 | 2000 | - | | | `hp_max` | 最大生命 | 100 | 2000 | - | |
| `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 | | `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 |
| `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。 | | `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。 | | `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 | - | 越低越快 | | `recharge_speed_mod` | 充能速度修正 | 1.0 (100%) | 5.0 | - | 越低越快 |
| `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 | | `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 |
| `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。 | | `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。 |
@@ -31,13 +31,18 @@
* **金币 (Gold)** (代号 `G`) * **金币 (Gold)** (代号 `G`)
* **来源**: 击杀怪物 (1-5G),波次结算 (100G + 10%利息)。 * **来源**: 击杀怪物 (1-5G),波次结算 (100G + 10%利息)。
* **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*)。 * **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*),购买属性 (60-120G 起,几何递增,见下)
* **利息上限**: 10% 利息计算时,单次波次奖励上限为 **100G**(即持有 1000G 以上部分不再产生额外利息)。防止后期经济失控膨胀。 * **利息上限**: 10% 利息计算时,单次波次奖励上限为 **100G**(即持有 1000G 以上部分不再产生额外利息)。防止后期经济失控膨胀。
* **膨胀控制**: 商店刷新价格每次增加,波次结束后重置。金币不捡 5 秒后消失,逼迫玩家移动拾取。 * **膨胀控制**: 商店刷新价格每次增加,波次结束后重置。金币不捡 5 秒后消失,逼迫玩家移动拾取。
- **刷新价格公式(F11 补充,P6-N23)**`刷新费用 = 20 + (本波已刷新次数 × 10)`(单位:G)。 - **刷新价格公式(F11 补充,P6-N23)**`刷新费用 = 20 + (本波已刷新次数 × 10)`(单位:G)。
即第 1 次刷新 20G,第 2 次 30G,第 3 次 40G,以此类推;波次结算后重置为 20G。 即第 1 次刷新 20G,第 2 次 30G,第 3 次 40G,以此类推;波次结算后重置为 20G。
设计意图:前 1-2 次刷新成本低,鼓励灵活换货;反复刷新代价指数感,抑制"无脑刷新"。 设计意图:前 1-2 次刷新成本低,鼓励灵活换货;反复刷新代价指数感,抑制"无脑刷新"。
实现:`ShopManager` 维护 `_refresh_count: int`(波次内),`WAVE_COMPLETE` 事件后清零。 实现:`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)** * **经验值 (XP)**
* **升级曲线(权威公式 P6-N16** * **升级曲线(权威公式 P6-N16**
@@ -96,7 +96,9 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置,
- `recharge_speed_mod` —— 代码中**不存在充能系统**(全项目 grep `recharge` 零命中),属新增机制而非断线。 - `recharge_speed_mod` —— 代码中**不存在充能系统**(全项目 grep `recharge` 零命中),属新增机制而非断线。
- 另:`mana_max` 的「与法杖取 Min」语义(权威 §1.1)未迁入框架,现状是核心值直接覆盖玩家值;那是独立设计判断,记为已知遗留。 - 另:`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`)而非继续挪这个全局数 —— 属独立立项,**不是本期遗留待办**。 -**`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(属性购买)**:商店可购属性词条(消耗金币/以太),叠加到 PlayerStats。S~M 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。 3. **货架 B(核心抽取)**:商店提供 Core 抽取/更换(复用 `_CORE_ROSTER`),与现有换杖打通。S~M。
4. **出售退款 G5**:背包内法术/核心可出售,按 `numerical` 退款比例返还货币。S。 4. **出售退款 G5**:背包内法术/核心可出售,按 `numerical` 退款比例返还货币。S。
@@ -170,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/截图实测。 **验收标准**:飘字随命中显示;屏外敌人低频更新且 FPS 提升;4 语言无裸中文;MCP/截图实测。
File diff suppressed because it is too large Load Diff
@@ -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 _inv_btn: Button = null
var _lang_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_layer: CanvasLayer = null
var _inv_root: Control = null var _inv_root: Control = null
@@ -71,6 +77,7 @@ func _ready() -> void:
_setup_world_view() _setup_world_view()
_setup_hud() _setup_hud()
_setup_shop_ui() _setup_shop_ui()
_setup_attr_shop_ui()
_setup_inventory_ui() _setup_inventory_ui()
_setup_settlement_ui() _setup_settlement_ui()
_setup_settings_ui() _setup_settings_ui()
@@ -84,6 +91,8 @@ func _input(event: InputEvent) -> void:
_close_inventory() _close_inventory()
elif _settings_layer and _settings_layer.visible: elif _settings_layer and _settings_layer.visible:
_close_settings() _close_settings()
elif _attr_layer and _attr_layer.visible:
_close_attr_shop()
elif _settle_layer and _settle_layer.visible: elif _settle_layer and _settle_layer.visible:
pass # 结算屏:ESC 无效,必须按按钮 pass # 结算屏:ESC 无效,必须按按钮
elif _shop_layer and _shop_layer.visible: elif _shop_layer and _shop_layer.visible:
@@ -343,6 +352,15 @@ func _setup_shop_ui() -> void:
settings_btn.connect("pressed", _open_settings) settings_btn.connect("pressed", _open_settings)
_shop_layer.add_child(settings_btn) _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 = Button.new()
_close_btn.text = tr("SHOP_NEXT_WAVE") _close_btn.text = tr("SHOP_NEXT_WAVE")
_close_btn.position = Vector2(550, 400) _close_btn.position = Vector2(550, 400)
@@ -356,6 +374,67 @@ func _setup_shop_ui() -> void:
_shop_warn.visible = false _shop_warn.visible = false
_shop_layer.add_child(_shop_warn) _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 刷新 ──────────────────────────────────────────────── # ── HUD 刷新 ────────────────────────────────────────────────
func _refresh_hud() -> void: func _refresh_hud() -> void:
@@ -403,6 +482,15 @@ func _refresh_shop_ui() -> void:
_core_btn.text = tr("SHOP_CORE_BTN") % _cm.get_core_display() _core_btn.text = tr("SHOP_CORE_BTN") % _cm.get_core_display()
if _lang_btn: if _lang_btn:
_lang_btn.text = "🌐 " + Locale.get_locale() _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()
# ── 事件 ───────────────────────────────────────────────── # ── 事件 ─────────────────────────────────────────────────
+25
View File
@@ -37,6 +37,7 @@ var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now
# 属性定义与加成来源(非热路径) # 属性定义与加成来源(非热路径)
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读 var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}] var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}]
var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取
# ── 魔力(MVP)───────────────────────────────────────── # ── 魔力(MVP)─────────────────────────────────────────
var mana: float = 100.0 var mana: float = 100.0
@@ -233,6 +234,14 @@ func _recompute_attrs() -> void:
cast_delay_mod = _compute_attr("cast_delay_mod") cast_delay_mod = _compute_attr("cast_delay_mod")
hp_max = _compute_attr("hp_max") hp_max = _compute_attr("hp_max")
hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > 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() stats_changed.emit()
func _compute_attr(attr_id: String) -> float: func _compute_attr(attr_id: String) -> float:
@@ -247,3 +256,19 @@ func _compute_attr(attr_id: String) -> float:
assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id) assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id)
return 0.0 return 0.0
return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d) 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 尽力写最后一笔 ## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔
extends Node 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_A: String = "user://run_a.json"
const PATH_B: String = "user://run_b.json" const PATH_B: String = "user://run_b.json"
@@ -63,6 +63,9 @@ func load_run() -> Dictionary:
func apply_run(data: Dictionary) -> void: func apply_run(data: Dictionary) -> void:
if data.is_empty(): if data.is_empty():
return 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", {})) PlayerStats.load_save_data(data.get("player_stats", {}))
WaveManager.current_wave = int(data.get("wave_num", 0)) WaveManager.current_wave = int(data.get("wave_num", 0))
var shop_seed: int = int(data.get("shop_seed", 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, "wave_num": WaveManager.current_wave,
"shop_seed": ShopManager.get_shop_seed(), "shop_seed": ShopManager.get_shop_seed(),
"player_stats": PlayerStats.get_save_data(), "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"): if is_instance_valid(_wand_provider) and _wand_provider.has_method("get_wand_save_data"):
d["wand"] = _wand_provider.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: if ver < 2:
# v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段 # v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段
data["schema_version"] = 2 data["schema_version"] = 2
if ver < 3:
# v2→v3: 旧档无 attr_purchasesapply_run 的 data.get(..., {}) 已处理缺失,无需补字段
data["schema_version"] = 3
return data return data
+155
View File
@@ -10,12 +10,20 @@ const SLOT_COUNT: int = 3
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用 const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
const REROLL_STEP: int = 10 # 每次递增价格 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 current_slots: Array = [] # Array[SpellNode] null 表示已售出)
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置) var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07 var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07
var _rng: RandomNumberGenerator = RandomNumberGenerator.new() 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: func _ready() -> void:
_load_attr_definitions()
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete) EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
func _on_wave_complete(_payload: Dictionary) -> void: func _on_wave_complete(_payload: Dictionary) -> void:
@@ -86,7 +94,154 @@ func close_shop() -> void:
func get_shop_seed() -> int: func get_shop_seed() -> int:
return _shop_seed 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: func reset() -> void:
current_slots.clear() current_slots.clear()
reroll_count = 0 reroll_count = 0
_shop_seed = 0 _shop_seed = 0
_attr_purchases.clear()
_apply_attr_purchases() # 撤销 shop_c 加成(PlayerStats.reset_for_run 已清 _modifiers,此处保证独立调用时也正确)
+5
View File
@@ -39,6 +39,11 @@ func _ready() -> void:
## 开始新局 ## 开始新局
func start_game() -> void: func start_game() -> void:
PlayerStats.reset_for_run() 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() WaveManager.reset()
BulletManager.reset() BulletManager.reset()
EnemyManager.reset() EnemyManager.reset()
+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
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 Inventory"
msgid "SHOP_SOLD" msgid "SHOP_SOLD"
msgstr "(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" msgid "INV_TITLE"
msgstr "🎒 Inventory — [%s] %d slots (%s)" msgstr "🎒 Inventory — [%s] %d slots (%s)"
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 バッグ"
msgid "SHOP_SOLD" msgid "SHOP_SOLD"
msgstr "(売却済)" 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" msgid "INV_TITLE"
msgstr "🎒 バッグ — [%s] %d スロット (%s)" msgstr "🎒 バッグ — [%s] %d スロット (%s)"
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 背包"
msgid "SHOP_SOLD" msgid "SHOP_SOLD"
msgstr "(已售出)" 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" msgid "INV_TITLE"
msgstr "🎒 背包 — [%s] %d 槽 (%s)" msgstr "🎒 背包 — [%s] %d 槽 (%s)"
+15
View File
@@ -51,6 +51,21 @@ msgstr "🎒 背包"
msgid "SHOP_SOLD" msgid "SHOP_SOLD"
msgstr "(已售出)" 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" msgid "INV_TITLE"
msgstr "🎒 背包 — [%s] %d 槽 (%s)" msgstr "🎒 背包 — [%s] %d 槽 (%s)"