Compare commits
25
Commits
9a5f2c1271
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db2f8e76b4 | ||
|
|
06f0049221 | ||
|
|
ce4ce2b392 | ||
|
|
8680e8c0ef | ||
|
|
5889b3c1e5 | ||
|
|
56d7eefdc8 | ||
|
|
7c8c1ec564 | ||
|
|
0b631a475f | ||
|
|
d69389e09d | ||
|
|
ad98f916e9 | ||
|
|
cc57980e63 | ||
|
|
f9486083e3 | ||
|
|
900eaea697 | ||
|
|
f8e02b0a0b | ||
|
|
68e3edbc7d | ||
|
|
25bdc83c93 | ||
|
|
622da4a18e | ||
|
|
6aeadb84b8 | ||
|
|
359da9f24f | ||
|
|
c6e89a0787 | ||
|
|
24948c77f8 | ||
|
|
e41a32b751 | ||
|
|
e069b76ee1 | ||
|
|
06d66dbd1a | ||
|
|
d7420a284f |
@@ -25,3 +25,6 @@ gdextension/**/~*
|
||||
gdextension/**/*.obj.import
|
||||
**/.sconsign.dblite
|
||||
compile_commands.json
|
||||
|
||||
# superpowers 子代理工作区(账本/简报/评审包,git-ignored scratch)
|
||||
.superpowers/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
_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 必须是 flat(add_int 拒绝 pct);shop 段缺失即该属性不可购买。"
|
||||
# dock 的 ScrollContainer 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句)
|
||||
tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
tip.modulate = Color(0.7, 0.7, 0.7)
|
||||
@@ -99,6 +124,24 @@ func _collect() -> Dictionary:
|
||||
entry["hard"] = _clean(r["hard"].value)
|
||||
# 存 key,不存双语显示串;selected 恒为 0..2(UI.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,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.05, "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" } }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| `hp_max` | 最大生命 | 100 | 2000 | - | |
|
||||
| `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。 |
|
||||
| `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 | - | 越低越快 |
|
||||
| `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` 逐杖 3–8,以 `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`)
|
||||
* **来源**: 击杀怪物 (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)**:
|
||||
|
||||
@@ -96,7 +96,9 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置,
|
||||
- `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(属性购买)**:商店可购属性词条(消耗金币/以太),叠加到 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。
|
||||
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/截图实测。
|
||||
|
||||
|
||||
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 | 12–17 次 |
|
||||
|
||||
**`cpu_limit` 必须 `flat`** —— `AttributeFormula` 的 `add_int` 明确拒绝 `pct` 并 `push_error`(乘算对离散指令预算无意义)。这条硬约束正是「增量必须逐属性配置」的原因,不是偏好。
|
||||
|
||||
首购 60–120G 与最便宜的法术(50G)同档,形成真实竞争(每波收入约 150–250G:击杀 1–5G + 波次结算 100G + 10% 利息)。
|
||||
|
||||
**为什么 `pct` 配 `geometric`**:`pct` 模式下第 n 次购买的**绝对收益**是 `base × (1+step)^(n-1) × step`,本身几何增长;价格若只线性涨,后期购买会越来越划算,最优解退化为「无脑堆一条轨」,取舍消失。取 `price_growth` 略大于 `1+step` 得到温和的边际递减 —— 每次仍值得买,但一直堆同轨会逐渐不划算,自然鼓励铺开。
|
||||
|
||||
> **刷新费用(P6-N23)用线性是对的**,因为刷新的价值每次都一样;属性购买的价值在复利,情况不同,不该照搬。
|
||||
|
||||
**价格递增按「本局该属性累计购买次数」计,不随波次重置**(与刷新费用相反)—— 它代表累计投资深度,波次重置会让「每波买一次」变成无脑最优。
|
||||
|
||||
### 2.3 `cast_delay_mod` 的饱和点够不到 —— `soft` 才是约束(自审订正)
|
||||
|
||||
E3-① 实测:`_handle_auto_cast` 用 `if` 而非 `while`,每物理帧至多施法一次,故存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic` 0.0333、`wand_fast` 0.0667、`circuit_fork` 0.0278。
|
||||
|
||||
**但正常购买够不到任何一个饱和点。** `cast_delay_mod` 越低越快,购买从 1.0 往下走,而 `soft`(0.1) 先把你拦住;三个饱和点(0.0333 / 0.0667 / 0.0278)**全部低于 0.1**:
|
||||
|
||||
| 法杖 | 基准间隔 | `soft`(0.1) 时的实际间隔 | 帧距 | 速率 | 饱和? |
|
||||
| :-- | --: | --: | --: | --: | :-- |
|
||||
| `wand_basic` | 0.5 s | 0.050 s | 3 帧 | 20 /s | 否(饱和需 ≤ 0.0333) |
|
||||
| `wand_fast` | 0.25 s | 0.025 s | 2 帧 | 30 /s | 否(饱和需 ≤ 0.0667) |
|
||||
| `circuit_fork` | 0.6 s | 0.060 s | 4 帧 | 15 /s | 否(饱和需 ≤ 0.0278) |
|
||||
|
||||
**故货架 C 的 UI 只需 `soft` 一道闸,不需要动态的逐杖饱和判定。**
|
||||
|
||||
> 📌 **自审订正记录**:本节初稿写「饱和点高于 `soft`,买到 `soft` 之前就已饱和,UI 必须做逐杖动态判定」—— **方向搞反了**。`inverse` 属性的「高/低」在语义上易反:数值更低 = 更快 = 更好,故「饱和点 0.0333 低于 soft 0.1」意味着**先撞 soft**,而非先饱和。差点规定了一个永远触发不了的 UI 功能。教训与本项目已记录的三次「断言宽到抓不住」同源:**`inverse` 语义下任何「高于/低于」的论断都必须代入具体数字验算一次**。
|
||||
|
||||
饱和点只在**将来出现能突破 `soft` 的特殊来源**(传奇核心 / 元进展)时才成为约束,届时才需要那套判定。完整实测数据见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。
|
||||
|
||||
### 2.4 状态与唯一写入点
|
||||
|
||||
`ShopManager` 新增 `_attr_purchases: Dictionary`(`attr_id: String` → `次数: int`)。
|
||||
|
||||
购买流程:
|
||||
```
|
||||
校验 shop 段存在 → 校验金币足够 → 校验未达 soft / 未饱和
|
||||
→ gold -= PriceFormula.compute(spec, n)
|
||||
→ _attr_purchases[id] = n + 1
|
||||
→ _apply_attr_purchases()
|
||||
```
|
||||
|
||||
`_apply_attr_purchases()` 重建加成(与 `_rebuild_wand` 处理 `"core"` 完全同构):
|
||||
```gdscript
|
||||
PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
|
||||
for id in _attr_purchases:
|
||||
var n: int = _attr_purchases[id]
|
||||
if n <= 0: continue
|
||||
var sp: Dictionary = <attributes.json[id].shop>
|
||||
# pct 合并须按 combine 分支:AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1−v
|
||||
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` 一起确立命名/签名/测试惯例。
|
||||
@@ -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()
|
||||
|
||||
# ── 事件 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -37,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
|
||||
@@ -233,6 +234,14 @@ 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:
|
||||
@@ -247,3 +256,19 @@ func _compute_attr(attr_id: String) -> float:
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔
|
||||
extends Node
|
||||
|
||||
const SCHEMA_VERSION: int = 2 # v2:新增 wand(Core/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_purchases;apply_run 的 data.get(..., {}) 已处理缺失,无需补字段
|
||||
data["schema_version"] = 3
|
||||
return data
|
||||
|
||||
@@ -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)^n−1
|
||||
# inverse: 需 f=(1−step)^n → merged=1−(1−step)^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,此处保证独立调用时也正确)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 而非 Curve:Godot 引擎自带全局类 `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 本身推到 INF(n 极大,如 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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4h1n0puafawv
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user