From 24948c77f8b5d4a8de6af9d96125239ae375ee69 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 10:52:10 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E9=A2=84=E6=A3=80=E8=AE=A2?= =?UTF-8?q?=E6=AD=A3=E2=80=94=E2=80=94=E6=B6=88=E9=99=A4=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E5=B1=9E=E6=80=A7=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E5=85=91=E7=8E=B0=E3=80=8C=E5=8A=A0=E5=B1=9E=E6=80=A7=E9=9B=B6?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开工前扫描发现计划初稿违反了它自己的 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 --- .../2026-07-31-shelf-c-attribute-shop.md | 83 ++++++++++++------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 7ee7c9b..f723475 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -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"]: