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>
This commit is contained in:
@@ -206,12 +206,45 @@ EOF
|
||||
### Task 2: `shop` 段数据 + `ShopManager` 货架 C 状态与购买
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/autoloads/player_stats.gd`(通用访问器,见 Step 0)
|
||||
- Modify: `data/attributes.json`(四属性各加 `shop` 段)
|
||||
- Modify: `scripts/autoloads/shop_manager.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PriceFormula.compute(spec, purchased) -> int`(Task 1);`PlayerStats.add_modifier(attr_id, mode, value, source)` / `remove_modifiers_from(source)` / `spend_gold(amount) -> bool`
|
||||
- Produces: `ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`;`get_sellable_attrs() -> Array[String]`;`get_attr_price(attr_id: String) -> int`;`get_attr_purchases(attr_id: String) -> int`;`can_buy_attribute(attr_id: String) -> String`(返回 `""` 表示可买,否则为禁用原因);`buy_attribute(attr_id: String) -> bool`;`get_attr_purchases_save() -> Dictionary`;`apply_attr_purchases_save(d: Dictionary) -> void`
|
||||
- Produces: `PlayerStats.get_attr_value(attr_id: String) -> float`;`ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`;`get_sellable_attrs() -> Array[String]`;`get_attr_def(attr_id: String) -> Dictionary`;`get_attr_price(attr_id: String) -> int`;`get_attr_purchases(attr_id: String) -> int`;`can_buy_attribute(attr_id: String) -> String`(返回 `""` 表示可买,否则为禁用原因);`buy_attribute(attr_id: String) -> bool`;`get_attr_purchases_save() -> Dictionary`;`apply_attr_purchases_save(d: Dictionary) -> void`
|
||||
|
||||
- [ ] **Step 0: `PlayerStats` 加通用生效值访问器(消除硬编码属性名)**
|
||||
|
||||
> 📌 **预检发现(2026-07-31,开工前)**:本计划初稿在 `ShopManager._effective_value()` 与 `combat_s2` 的两个辅助里各写了一个 `match attr_id:` 硬编码四个属性名。**那违反本计划自己的 Global Constraint(纯数据驱动、代码不含硬编码副本),也违反本特性的立身之本「加可售属性 = 加 JSON 段,零代码」** —— 第 5 个属性加 `shop` 段后,`get_sellable_attrs()` 会返回它、UI 会生成那一行,但生效值读出 `0.0` 并 `push_error`、名字显示成裸 id。故先加通用访问器,三处 `match` 全部删除。
|
||||
|
||||
`player_stats.gd`:在 `_modifiers` 声明(约 :39)之后新增
|
||||
```gdscript
|
||||
var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取
|
||||
```
|
||||
|
||||
`_recompute_attrs()` 内,在写回裸字段之后、`stats_changed.emit()` 之前追加:
|
||||
```gdscript
|
||||
# 冷路径通用视图:热路径(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,
|
||||
}
|
||||
```
|
||||
文件末尾新增:
|
||||
```gdscript
|
||||
## 按 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])
|
||||
```
|
||||
|
||||
> 注:`_attr_effective` 的键仍逐个列出,因为裸字段本身就是逐个声明的(热路径要求)。但**消费方**(商店、UI)自此不再硬编码属性名 —— 新增属性时只需在此表加一行,而不是在三个文件里各改一个 `match`。这是「热路径零开销」与「消费端数据驱动」之间的必要接缝。
|
||||
|
||||
- [ ] **Step 1: `attributes.json` 四属性加 `shop` 段**
|
||||
|
||||
@@ -302,20 +335,15 @@ 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 = _effective_value(attr_id)
|
||||
var cur: float = PlayerStats.get_attr_value(attr_id)
|
||||
if String(d.get("combine", "hybrid")) == "inverse":
|
||||
return cur <= soft
|
||||
return cur >= soft
|
||||
|
||||
## 读取生效值(裸字段,读取端零开销)
|
||||
func _effective_value(attr_id: String) -> float:
|
||||
match attr_id:
|
||||
"cpu_limit": return float(PlayerStats.cpu_limit)
|
||||
"move_speed": return PlayerStats.move_speed
|
||||
"cast_delay_mod": return PlayerStats.cast_delay_mod
|
||||
"hp_max": return PlayerStats.hp_max
|
||||
push_error("ShopManager: 未知属性「%s」,无法读取生效值" % attr_id)
|
||||
return 0.0
|
||||
## 属性定义(供 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)
|
||||
@@ -544,38 +572,29 @@ var _attr_rows: Array = [] # [{id: String, label: Label, button: Button}]
|
||||
var reason: String = ShopManager.can_buy_attribute(id)
|
||||
var price: int = ShopManager.get_attr_price(id)
|
||||
var n: int = ShopManager.get_attr_purchases(id)
|
||||
row["label"].text = tr("SHOP_ATTR_ROW") % [_attr_display_name(id), _attr_effective_text(id), n]
|
||||
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()
|
||||
```
|
||||
新增两个辅助与回调:
|
||||
新增回调:
|
||||
```gdscript
|
||||
func _attr_display_name(id: String) -> String:
|
||||
match id:
|
||||
"cpu_limit": return tr("ATTR_CPU_LIMIT")
|
||||
"move_speed": return tr("ATTR_MOVE_SPEED")
|
||||
"cast_delay_mod": return tr("ATTR_CAST_DELAY")
|
||||
"hp_max": return tr("ATTR_HP_MAX")
|
||||
return id
|
||||
|
||||
func _attr_effective_text(id: String) -> String:
|
||||
match id:
|
||||
"cpu_limit": return str(PlayerStats.cpu_limit)
|
||||
"move_speed": return "%.0f" % PlayerStats.move_speed
|
||||
"cast_delay_mod": return "%.2f" % PlayerStats.cast_delay_mod
|
||||
"hp_max": return "%.0f" % PlayerStats.hp_max
|
||||
return "-"
|
||||
|
||||
func _on_buy_attr_pressed(id: String) -> void:
|
||||
if ShopManager.buy_attribute(id):
|
||||
_refresh_shop_ui()
|
||||
```
|
||||
|
||||
> 📌 **预检订正**:初稿在此写了 `_attr_display_name()` 与 `_attr_effective_text()` 两个 `match id:` 辅助,**各硬编码一遍四个属性名** —— 而 `display_name` 本就在 `attributes.json` 里、生效值有 `PlayerStats.get_attr_value()`。两个辅助全部删除,改为读数据。第 5 个属性自此无需改动本文件。
|
||||
> 统一用 `%.2f` 显示:`cast_delay_mod` 需要两位小数,而 `hp_max`/`move_speed` 显示 `133.10` 也可接受 —— 逐属性定制格式又会引回一张硬编码表。若日后确需,把格式串放进 `attributes.json` 而非代码。
|
||||
刷新函数名**已核实为 `_refresh_shop_ui()`**(`combat_s2.gd:413` 的 `_on_stats_changed` 调用它)。
|
||||
|
||||
- [ ] **Step 4: 补 i18n 键(4 语言)**
|
||||
|
||||
`translations/` 下 `zh_CN.po` / `zh_TW.po` / `en.po` / `ja.po` **各加 6 个键**,键集必须一致(项目已验证 4 语言零缺键):
|
||||
`SHOP_ATTR_TITLE`、`SHOP_ATTR_ROW`(格式 `%s 当前 %s 已购 %d 次`)、`SHOP_ATTR_BUY`(`购买 %dG`)、`ATTR_CPU_LIMIT`、`ATTR_MOVE_SPEED`、`ATTR_CAST_DELAY`、`ATTR_HP_MAX`。
|
||||
`translations/` 下 `zh_CN.po` / `zh_TW.po` / `en.po` / `ja.po` **各加 3 个键**,键集必须一致(项目已验证 4 语言零缺键):
|
||||
`SHOP_ATTR_TITLE`、`SHOP_ATTR_ROW`(格式 `%s 当前 %s 已购 %d 次`)、`SHOP_ATTR_BUY`(`购买 %dG`)。
|
||||
|
||||
> 📌 **预检订正**:初稿还要求加 `ATTR_CPU_LIMIT` 等 4 个属性名键 —— 那是硬编码属性表的另一种形态(加第 5 个属性就要改 4 个 `.po`)。属性显示名读 `attributes.json` 的 `display_name`(已是「中文 English」双语),故这 4 个键不需要。
|
||||
> 代价如实说明:`display_name` 不经 `tr()`,即属性名不随语言切换 —— 但这与法术/核心/状态的 `display_name` 现状一致(路线图 E7-③「内容名 tr 化」是统一处理这批债务的独立小项),**本期不制造新的例外**。
|
||||
|
||||
⚠️ `can_buy_attribute` 返回的禁用原因目前是**中文裸串**(在 `shop_manager.gd` 里)。本期先如此;i18n 债务(内容名 tr 化)是路线图 E7-③ 的独立小项,**不在本期扩大范围**,但要在 `can_buy_attribute` 的注释里注明这一点。
|
||||
|
||||
@@ -585,7 +604,7 @@ Run: godot-mcp-pro `validate_script` on `res://scenes/main/combat_s2.gd` — `va
|
||||
|
||||
Run: godot-mcp-pro `execute_editor_script` 校验 4 语言键集一致:
|
||||
```gdscript
|
||||
var keys := ["SHOP_ATTR_TITLE", "SHOP_ATTR_ROW", "SHOP_ATTR_BUY", "ATTR_CPU_LIMIT", "ATTR_MOVE_SPEED", "ATTR_CAST_DELAY", "ATTR_HP_MAX"]
|
||||
var keys := ["SHOP_ATTR_TITLE", "SHOP_ATTR_ROW", "SHOP_ATTR_BUY"]
|
||||
var fails := 0
|
||||
var bad := []
|
||||
for loc in ["zh_CN", "zh_TW", "en", "ja"]:
|
||||
|
||||
Reference in New Issue
Block a user