diff --git a/docs_dev/plans/2026-07-31-player-attributes.md b/docs_dev/plans/2026-07-31-player-attributes.md new file mode 100644 index 0000000..857800b --- /dev/null +++ b/docs_dev/plans/2026-07-31-player-attributes.md @@ -0,0 +1,695 @@ +# 玩家属性系统(Player Attributes)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立数据驱动的玩家属性框架(含独立的加成公式模块),接线三条既有死数据(`cpu_limit` / `move_speed` / `cast_delay_mod`),并为货架 C 提供 `add_modifier` 接入点。 + +**Architecture:** 公式与状态分离。`AttributeFormula`(`class_name` 纯静态模块,零依赖)是所有合成公式的唯一实现处,因此可脱离游戏进程单元断言;`PlayerStats` 只负责加载 `attributes.json`、持有加成列表、把公式结果写进静态类型裸字段(读取端零开销)。法杖的 `cpu_limit` 以 `source="core"` 的加成来源接入(换杖时重算,与既有 `mana_leech` 同处同时机),使 `hard` 上限天然作用于生效总值。 + +**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro(公式为纯函数 → `execute_editor_script` 单元断言;接线为运行时 `execute_game_script` 实测)。 + +> **规范**:提交中文;纯数据驱动,文件缺失 `push_error` 不静默回退。spec:`docs_dev/specs/2026-07-31-player-attributes-design.md`。 +> **已知工具坑**:① `validate_script` 对任何带 `class_name` 的脚本必然假阴性(报 `hides a global script class`)—— `attribute_formula.gd` 属此类,验证改用 `GDScript.new()` + 从磁盘读 `source_code` + `reload()`;`player_stats.gd` / `player_manager.gd` / `spell_evaluator.gd` / `combat_manager.gd` 无 `class_name`,可正常 `validate_script`。② `@tool` 编辑器类在 dock 里被缓存,`load(...).new()` 拿到旧类,绕法同上。③ `execute_editor_script` 静默阻止运行时 `FileAccess.WRITE`。 +> **关键既有事实**:`spell_evaluator.gd:332` `var max_ops: int = MAX_OPS_PER_CPU * 5`(`MAX_OPS_PER_CPU = 40`,`:8`)。`player_manager.gd:7` `const MOVE_SPEED: float = 200.0`,`:45` 消费,`:88` `_cast_interval = core.cast_interval if core else 0.5`。`combat_manager.gd:257` `_rebuild_wand()`,`:267-271` 为 `mana_leech` 重算段。`player_stats.gd`:`hp_max` :10、`cpu_limit` :21、`armor` :22、`resistance` :23、`_ready()` :33、`reset_for_run()` :125、`get_save_data()` :135、`load_save_data()` :138。`CompiledDeck` **没有** `cpu_limit` 字段(只有 `feature_tags`),故 `spell_evaluator` 侧不需要新增字段。 + +--- + +### Task 0: 建功能分支 + +- [ ] **Step 1: 从 master 切分支** + +spec 与本计划已提交在 `master`;CLAUDE.md 规定主分支上不做功能开发。 + +```bash +git checkout -b feat/player-attributes +``` + +Expected: `Switched to a new branch 'feat/player-attributes'` + +--- + +### Task 1: `AttributeFormula` 公式模块(TDD) + +**Files:** +- Create: `scripts/domain/attribute_formula.gd` +- Create: `scripts/domain/attribute_formula.gd.uid`(Godot 自动生成,随后提交) + +> 本任务是纯函数,**先写断言、看它失败、再实现**。断言脚本不落盘为文件(项目无测试框架),而是经 `execute_editor_script` 执行 —— 与既有验收方式一致。 + +- [ ] **Step 1: 先跑断言脚本,确认它因类不存在而失败** + +Run: godot-mcp-pro `execute_editor_script`: +```gdscript +_mcp_print("exists=%s" % str(ClassDB.class_exists("AttributeFormula") or ResourceLoader.exists("res://scripts/domain/attribute_formula.gd"))) +``` +Expected: `exists=false`。若为 `true` 说明文件已存在,先确认不是残留。 + +- [ ] **Step 2: 实现 `AttributeFormula`** + +Create `scripts/domain/attribute_formula.gd`: +```gdscript +## AttributeFormula — 属性合成公式的唯一实现处 +## 纯静态函数:无状态、不依赖 PlayerStats / EventBus / 场景树,故可脱离游戏进程单元测试 +## 权威来源:docs_dev/specs/2026-07-31-player-attributes-design.md §2.1 / §2.3 +class_name AttributeFormula +extends RefCounted + +enum Combine { HYBRID, INVERSE, ADD_INT } + +const _COMBINE_NAMES: Dictionary = { + "hybrid": Combine.HYBRID, + "inverse": Combine.INVERSE, + "add_int": Combine.ADD_INT, +} + +## JSON 的 combine 字符串 → 枚举;未知值 push_error 并回退 HYBRID +static func combine_from_string(s: String) -> Combine: + if _COMBINE_NAMES.has(s): + return _COMBINE_NAMES[s] + push_error("AttributeFormula: 未知 combine「%s」,回退 hybrid" % s) + return Combine.HYBRID + +## 唯一的公式入口 +## base —— attributes.json 的基准值 +## mods —— [{"mode": "flat"|"pct", "value": float}, ...];调用方负责只传本属性的加成 +## attr_def —— attributes.json 中该属性的定义(读 combine / hard) +## 返回统一为 float;add_int 属性由调用方做 int() 转换(公式模块不感知目标字段类型) +static func compute(base: float, mods: Array, attr_def: Dictionary) -> float: + var combine: Combine = combine_from_string(String(attr_def.get("combine", "hybrid"))) + var hard: float = float(attr_def.get("hard", 0.0)) + var flat_sum: float = 0.0 + var pct_prod: float = 1.0 + for m in mods: + var mode: String = String(m.get("mode", "flat")) + var v: float = float(m.get("value", 0.0)) + if mode == "flat": + flat_sum += v + elif mode == "pct": + if combine == Combine.ADD_INT: + push_error("AttributeFormula: add_int 属性不接受 pct 加成(value=%f),已忽略" % v) + continue + # 连乘而非线性求和:三条 +20% = ×1.728 而非 ×1.6,避免后期线性失控 + pct_prod *= (1.0 + v) if combine == Combine.HYBRID else (1.0 - v) + else: + push_error("AttributeFormula: 未知 mode「%s」,已忽略" % mode) + match combine: + Combine.ADD_INT: + var ri: float = floorf(base + flat_sum) + return clampf(ri, 0.0, hard) if hard > 0.0 else maxf(0.0, ri) + Combine.INVERSE: + # 越低越快:hard 是**下限** + return maxf(hard, (base + flat_sum) * pct_prod) + _: + var r: float = maxf(0.0, (base + flat_sum) * pct_prod) + return minf(r, hard) if hard > 0.0 else r # hard=0.0 约定为「不钳制」 +``` + +- [ ] **Step 3: 跑公式单元断言(spec §4 验收 1–6)** + +Run: godot-mcp-pro `execute_editor_script`: +```gdscript +# 绕开 class_name 缓存/假阴性:从磁盘构造全新类 +var S := GDScript.new() +S.source_code = FileAccess.get_file_as_string("res://scripts/domain/attribute_formula.gd") +if S.reload() != OK: + _mcp_print("PARSE_FAIL"); return +var F = S + +var HYB := {"combine": "hybrid", "hard": 0.0} +var HYB_CAP := {"combine": "hybrid", "hard": 800.0} +var INV := {"combine": "inverse", "hard": 0.01} +var INT := {"combine": "add_int", "hard": 50.0} + +# ⚠️ 不要把断言写进 lambda 再累加外层 int —— GDScript 闭包对 int 是**按值捕获**, +# `fails += 1` 传不回外层,FAILS 会恒为 0,整个断言变空。用 Array 逐条收集。 +var m3 := [{"mode":"pct","value":0.2},{"mode":"pct","value":0.2},{"mode":"pct","value":0.2}] +var many := [] +for i in 20: many.append({"mode":"pct","value":0.5}) + +var cases := [ + ["①连乘", F.compute(1.0, m3, HYB), 1.728], + ["②混合", F.compute(200.0, [{"mode":"flat","value":50.0},{"mode":"pct","value":0.2}], HYB), 300.0], + ["③inverse", F.compute(1.0, m3, INV), 0.512], + ["③b下限", F.compute(1.0, many, INV), 0.01], + ["④拒pct", F.compute(0.0, [{"mode":"flat","value":5.0},{"mode":"pct","value":0.5},{"mode":"flat","value":8.0}], INT), 13.0], + ["⑤上限", F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB_CAP), 800.0], + ["⑤b不钳制", F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB), 5200.0], + ["⑥空", F.compute(123.0, [], HYB), 123.0], + ["⑥b未知", F.compute(1.0, m3, {"combine": "bogus", "hard": 0.0}), 1.728], + ["⑥c整数上限", F.compute(0.0, [{"mode":"flat","value":999.0}], INT), 50.0], +] +var fails := 0 +var bad := [] +for c in cases: + if abs(float(c[1]) - float(c[2])) > 0.0001: + fails += 1 + bad.append("%s got=%.6f want=%.6f" % [c[0], c[1], c[2]]) +_mcp_print("FAILS=%d %s" % [fails, str(bad)]) +``` +Expected: `FAILS=0 []` +> 预期输出中会伴随两条 `push_error` 文本(④ 的 add_int 拒 pct、⑥b 的未知 combine)—— **那是被断言的正确行为**,不是错误。 + +- [ ] **Step 4: 提交** + +```bash +git add scripts/domain/attribute_formula.gd scripts/domain/attribute_formula.gd.uid +git commit -F- <<'EOF' +feat(attr): AttributeFormula 公式模块——hybrid 连乘 / inverse 反向下限 / add_int 拒 pct + +公式集中于单一纯静态模块,无状态零依赖,故可脱离游戏进程单元断言。 +乘算用连乘而非线性求和:三条 +20% 得 ×1.728 而非 ×1.6,避免后期线性失控。 +add_int 对离散预算拒绝 pct 并 push_error,而非静默取整掩盖配置错误。 + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 2: `attributes.json` + `PlayerStats` 框架 + 删孤儿字段 + +**Files:** +- Create: `data/attributes.json` +- Modify: `scripts/autoloads/player_stats.gd` + +- [ ] **Step 1: 新建 `data/attributes.json`** + +```json +{ + "cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" }, + "move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" }, + "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.01, "combine": "inverse" }, + "hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" } +} +``` + +- [ ] **Step 2: `PlayerStats` 加属性框架** + +`player_stats.gd`:将 `var hp_max: float = 100.0`(:10)保留(生效值字段),并在「战斗属性」区(:21-23)**整段替换** —— 删 `cpu_limit` 旧声明、删 `armor`、删 `resistance`,改为: +```gdscript +# ── 属性(生效值:静态类型裸字段,读取端零开销)───────────── +# 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出,勿直接赋值 +var cpu_limit: int = 0 # 生效运算力(含法杖 "core" 加成);MAX_OPS = 本值 × 40 +var move_speed: float = 200.0 # 像素/秒 +var cast_delay_mod: float = 1.0 # 施法间隔乘算系数,越低越快 +var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now + iframe 窗口 + +# 属性定义与加成来源(非热路径) +var _attr_def: Dictionary = {} # attributes.json 全量定义,只读 +var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}] +``` +(`_invuln_until_msec` 原在 :24,保持不动,此处只是展示相邻上下文。) + +在 `_ready()`(:33)**开头**加载定义并首次重算(必须早于任何存档回读): +```gdscript +func _ready() -> void: + _load_attr_definitions() + _recompute_attrs() + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) +``` + +在文件末尾新增: +```gdscript +# ── 属性框架 ────────────────────────────────────────────── +const _ATTR_PATH: String = "res://data/attributes.json" + +func _load_attr_definitions() -> void: + if not FileAccess.file_exists(_ATTR_PATH): + push_error("PlayerStats: 缺少 %s" % _ATTR_PATH) + return + var parsed = JSON.parse_string(FileAccess.get_file_as_string(_ATTR_PATH)) + if not (parsed is Dictionary): + push_error("PlayerStats: %s 格式错误(应为对象)" % _ATTR_PATH) + return + _attr_def = parsed + +## 追加一条加成来源;同一 source 可对多个属性各加一条 +func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void: + _modifiers.append({"attr_id": attr_id, "mode": mode, "value": value, "source": source}) + _recompute_attrs() + +## 撤销某来源的全部加成(换杖、出售退款用) +func remove_modifiers_from(source: String) -> void: + var kept: Array[Dictionary] = [] + for m in _modifiers: + if String(m.get("source", "")) != source: + kept.append(m) + _modifiers = kept + _recompute_attrs() + +func _mods_for(attr_id: String) -> Array: + var out: Array = [] + for m in _modifiers: + if String(m.get("attr_id", "")) == attr_id: + out.append(m) + return out + +## 逐属性按各自 combine 公式重算生效值,写回裸字段 +func _recompute_attrs() -> void: + if _attr_def.is_empty(): + return + cpu_limit = int(_compute_attr("cpu_limit")) + move_speed = _compute_attr("move_speed") + cast_delay_mod = _compute_attr("cast_delay_mod") + hp_max = _compute_attr("hp_max") + stats_changed.emit() + +func _compute_attr(attr_id: String) -> float: + var d: Dictionary = _attr_def.get(attr_id, {}) + if d.is_empty(): + push_error("PlayerStats: attributes.json 缺少属性「%s」" % attr_id) + return 0.0 + return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d) +``` + +- [ ] **Step 3: 存档字段清理(`hp_max` / `cpu_limit` 现为派生值)** + +两者现在由 `attributes.json` + `_modifiers` 算出,存档回读会覆盖公式结果。`get_save_data()`(:135)与 `load_save_data()`(:138)改为: +```gdscript +func get_save_data() -> Dictionary: + return {"hp": hp, "gold": gold, "xp": xp, "level": level} + +func load_save_data(data: Dictionary) -> void: + hp = float(data.get("hp", 100.0)) + gold = int(data.get("gold", 0)) + xp = int(data.get("xp", 0)) + level = int(data.get("level", 1)) + xp_to_next = xp_for_level(level) + stats_changed.emit() +``` +旧存档里的 `hp_max` / `cpu_limit` 键被忽略即可,**不需要提升 schema 版本号**:`hp_max` 全项目从无写入方(永远是 100),`cpu_limit` 从未被读取过。 + +> **留给货架 C 的提示**(写进代码注释):一旦玩家可购买属性,需要持久化的是 `_modifiers`(来源列表)而非派生的生效值,届时 `get_save_data` 应加 `"attr_modifiers": _modifiers` 并提升 schema 版本。`source == "core"` 的那条**不要**持久化——它在换杖时由 `combat_manager._rebuild_wand` 重建。 + +在 `_recompute_attrs()` 上方加注释记录这一点。 + +- [ ] **Step 4: 校验语法 + 数据** + +Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/player_stats.gd` — `valid: true`(该文件无 `class_name`,失败即真错误)。 + +Run: godot-mcp-pro `execute_editor_script`: +```gdscript +var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/attributes.json")) +var fails := 0 +for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]: + if not (d is Dictionary) or not d.has(k): fails += 1 +if abs(float(d["move_speed"]["base"]) - 200.0) > 0.001: fails += 1 +if String(d["cpu_limit"]["combine"]) != "add_int": fails += 1 +if String(d["cast_delay_mod"]["combine"]) != "inverse": fails += 1 +var src := FileAccess.get_file_as_string("res://scripts/autoloads/player_stats.gd") +if src.contains("var armor") or src.contains("var resistance"): fails += 1 +_mcp_print("FAILS=%d" % fails) +``` +Expected: `FAILS=0` + +- [ ] **Step 5: 提交** + +```bash +git add data/attributes.json scripts/autoloads/player_stats.gd +git commit -F- <<'EOF' +feat(attr): attributes.json + PlayerStats 属性框架;删孤儿字段 armor/resistance + +PlayerStats 只负责加载定义、持有加成列表、把公式结果写进静态类型裸字段, +不含任何公式(公式在 AttributeFormula)。读取端零开销以满足热路径纪律 +(move_speed 每物理帧被 player_manager 读)。 + +armor/resistance 零消费方且不在 numerical_design §1.1 权威属性表内,属实现 +先于设计的残留,删除;真要做玩家侧减伤时按货架 C 属性词条立项(见 spec §2.6)。 + +hp_max/cpu_limit 现为派生值,从存档字段移除——回读会覆盖公式结果。 +旧存档相应键忽略即可,无需提升 schema 版本(hp_max 全项目从无写入方、 +cpu_limit 从未被读取)。 + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 3: 四处接线 + +**Files:** +- Modify: `scripts/domain/spell_system/spell_evaluator.gd:332` +- Modify: `scripts/domain/player_manager.gd:7,45,88` +- Modify: `scripts/domain/combat/combat_manager.gd:267-271` + +- [ ] **Step 1: MAX_OPS 读生效 `cpu_limit`** + +`spell_evaluator.gd`,把 `:332` 的 +```gdscript + var max_ops: int = MAX_OPS_PER_CPU * 5 +``` +改为 +```gdscript + # 生效 cpu_limit 已含法杖份额(combat_manager 以 source="core" 的加成接入) + var max_ops: int = MAX_OPS_PER_CPU * PlayerStats.cpu_limit +``` + +- [ ] **Step 2: 移速改读属性** + +`player_manager.gd`:删除 `:7` 整行 `const MOVE_SPEED: float = 200.0`;把 `:45` 的 +```gdscript + _velocity = dir * MOVE_SPEED +``` +改为 +```gdscript + _velocity = dir * PlayerStats.move_speed +``` + +- [ ] **Step 3: 施法间隔乘 `cast_delay_mod`** + +`player_manager.gd` 的 `equip_wand`(:85),把 `:88` 的 +```gdscript + _cast_interval = core.cast_interval if core else 0.5 +``` +改为 +```gdscript + _cast_interval = (core.cast_interval if core else 0.5) * PlayerStats.cast_delay_mod +``` + +- [ ] **Step 4: 法杖 `cpu_limit` 作为加成来源接入** + +`combat_manager.gd` 的 `_rebuild_wand()`(:257),在既有 `mana_leech` 重算段(:267-271)之后追加: +```gdscript + # 法杖运算力以加成来源接入(非调用点相加),使 hard 上限作用于生效总值 + PlayerStats.remove_modifiers_from("core") + PlayerStats.add_modifier("cpu_limit", "flat", float(_equipped_core.cpu_limit) if _equipped_core else 0.0, "core") +``` +(顺序要紧:先 `remove` 再 `add`,否则换杖会累积旧法杖份额。) + +- [ ] **Step 5: 校验语法** + +Run: godot-mcp-pro `validate_script` on `res://scripts/domain/spell_system/spell_evaluator.gd`、`res://scripts/domain/player_manager.gd`、`res://scripts/domain/combat/combat_manager.gd` — 均 `valid: true`。 + +- [ ] **Step 6: 提交** + +```bash +git add scripts/domain/spell_system/spell_evaluator.gd scripts/domain/player_manager.gd scripts/domain/combat/combat_manager.gd +git commit -F- <<'EOF' +feat(attr): 接线 MAX_OPS/移速/施法间隔;法杖 cpu_limit 以加成来源接入 + +MAX_OPS 由硬编码 40×5 改为读生效 cpu_limit——cores.json 里逐法杖配置的 +3/5/6/8 自此生效,法杖间恢复运算力区分度。玩家基准取 0,故小木法杖仍为 +5 → 200 步,现有平衡零改动。 + +法杖份额走 add_modifier 而非调用点相加:否则 hard(50) 只钳制玩家那一份, +法杖份额加在钳制之后可使总值越界。换杖时先 remove_modifiers_from("core") +再 add,防止累积。 + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 4: 设计器「属性」页 + +**Files:** +- Create: `addons/game_designer/attribute_tab.gd`(+ `.gd.uid`) +- Modify: `addons/game_designer/designer_panel.gd`(`_init()` 里 `_add_tab` 注册) + +> 按 CLAUDE.md「新增分页 3 步」:`@tool extends VBoxContainer` 标签脚本(复用 `designer_ui.gd`)→ `designer_panel.gd` 的 `_init()` 里 `_add_tab(...)` → 对应加载。控件一律经 `UI.spin/opt/btn/header/status_label/load_json/save_json` 创建,**不要**自建裸控件。标签「中文 English」双语序。`combine` 下拉**存 key 不存显示串**。 + +- [ ] **Step 1: 实现 `attribute_tab.gd`** + +签名已核对自 `designer_ui.gd`:`UI.spin(min, max, step, val) -> SpinBox`、`UI.opt(items: Array, selected: int) -> OptionButton`、`UI.header(text)`、`UI.cell_label(text)`、`UI.btn(text, cb)`、`UI.status_label()`、`UI.set_status(lbl, msg, is_err)`、`UI.load_json(path)`、`UI.save_json(path, data) -> bool`。结构照 `balance_tab.gd`。 + +Create `addons/game_designer/attribute_tab.gd`: +```gdscript +## 属性编辑器标签 — 玩家属性的 base / soft / hard / combine +## 数据:res://data/attributes.json(权威属性定义见 docs/design/numerical_design.md §1.1) +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/attributes.json" +# 显示与存储解耦:LABELS 仅供下拉显示,KEYS 才写进 JSON,二者 index 对齐 +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"] + +var _data: Dictionary = {} +var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton} +var _status: Label + +func _ready() -> void: + add_child(UI.header("📊 属性编辑器 — 玩家属性 base / soft / hard / combine")) + _load() + var grid := GridContainer.new(); grid.columns = 5; add_child(grid) + for h in ["属性", "基准 base", "软上限 soft", "硬上限 hard", "合成 combine"]: + var l := Label.new(); l.text = h; l.modulate = Color(0.7, 0.8, 1.0) + grid.add_child(l) + for attr_id in ATTR_ORDER: + var d: Dictionary = _data.get(attr_id, {}) + grid.add_child(UI.cell_label(String(d.get("display_name", attr_id)))) + var sp_base := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("base", 0.0))) + var sp_soft := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("soft", 0.0))) + var sp_hard := UI.spin(-99999.0, 99999.0, 0.01, float(d.get("hard", 0.0))) + var idx: int = COMBINE_KEYS.find(String(d.get("combine", "hybrid"))) + var op := UI.opt(COMBINE_LABELS, idx if idx >= 0 else 0) + grid.add_child(sp_base); grid.add_child(sp_soft); grid.add_child(sp_hard); grid.add_child(op) + _rows[attr_id] = {"base": sp_base, "soft": sp_soft, "hard": sp_hard, "combine": op} + add_child(HSeparator.new()) + var tip := Label.new() + tip.text = "hard=0 在 hybrid 下表示不钳制;inverse 的 hard 是**下限**;add_int 拒绝 pct 加成。" + tip.modulate = Color(0.7, 0.7, 0.7) + add_child(tip) + add_child(UI.btn("💾 保存 attributes.json", _save)) + _status = UI.status_label(); add_child(_status) + +func _load() -> void: + var d = UI.load_json(PATH) + _data = d if d is Dictionary else {} + +## 表单 → 字典(合并式:以 _data 为基底保留未知键) +func _collect() -> Dictionary: + var out: Dictionary = _data.duplicate(true) + for attr_id in ATTR_ORDER: + var r: Dictionary = _rows.get(attr_id, {}) + if r.is_empty(): + continue + var entry: Dictionary = out.get(attr_id, {}).duplicate(true) + entry["base"] = float(r["base"].value) + entry["soft"] = float(r["soft"].value) + entry["hard"] = float(r["hard"].value) + entry["combine"] = COMBINE_KEYS[r["combine"].selected] # 存 key,不存双语显示串 + out[attr_id] = entry + return out + +func _save() -> void: + if UI.save_json(PATH, _collect()): + UI.set_status(_status, "💾 已保存(重启 F5 生效)") + else: + UI.set_status(_status, "✗ 保存失败", true) +``` + +> **为什么 UI 建在 `_ready()` 而非 `_init()`**:`@tool` 面板的控件在节点未入树时操作会出错(项目已踩过:`text_edit.cpp` 索引越界)。七个既有分页都为此从 `_init` 改到了 `_ready`,勿重蹈。 +> **为什么 `_collect()` 以 `_data.duplicate(true)` 为基底**:防丢键。`balance_tab._save` 曾因新建空字典而丢掉 `mana_heat`/`infinite_spells`,已修,勿重犯。 + +- [ ] **Step 2: 在 `designer_panel.gd` 注册分页** + +在 `_init()` 的 `_add_tab` 序列末尾(现 `:19` 的「⚖ 平衡」之后)追加: +```gdscript + _add_tab(tabs, "📊 属性", preload("res://addons/game_designer/attribute_tab.gd").new()) +``` + +- [ ] **Step 3: 校验 + 往返实测** + +Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/attribute_tab.gd` 与 `res://addons/game_designer/designer_panel.gd` — 均 `valid: true`(两者均无 `class_name`,失败即真错误)。 + +Run: godot-mcp-pro `execute_editor_script`(用绕缓存法真实实例化并驱动真实控件,**不要**只 grep 源码文本 —— 那不是往返): +```gdscript +var S := GDScript.new() +S.source_code = FileAccess.get_file_as_string("res://addons/game_designer/attribute_tab.gd") +if S.reload() != OK: + _mcp_print("PARSE_FAIL"); return +var tab = S.new() +tab._ready() # 节点不入树也能建出真实 SpinBox/OptionButton +var orig = JSON.parse_string(FileAccess.get_file_as_string("res://data/attributes.json")) +var out: Dictionary = tab._collect() +var fails := 0 +var bad := [] +for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]: + for f in ["base", "soft", "hard"]: + if abs(float(orig[k][f]) - float(out[k][f])) > 0.0001: + fails += 1; bad.append("%s.%s %s→%s" % [k, f, str(orig[k][f]), str(out[k][f])]) + if String(orig[k]["combine"]) != String(out[k]["combine"]): + fails += 1; bad.append("%s.combine %s→%s" % [k, orig[k]["combine"], out[k]["combine"]]) +# combine 必须存 key 而非双语显示串 +if String(out["cpu_limit"]["combine"]) != "add_int": fails += 1; bad.append("combine 存了显示串") +# display_name 等未列入表单的键不得丢失 +if not out["cpu_limit"].has("display_name"): fails += 1; bad.append("丢键 display_name") +_mcp_print("ROUNDTRIP_FAILS=%d %s" % [fails, str(bad)]) +``` +Expected: `ROUNDTRIP_FAILS=0 []` +> 注意 `0.01` 的 SpinBox 步长:本期四个属性的 base/soft/hard 全部落在 0.01 格点上(0/20/50、200/600/800、1.0/0.1/0.01、100/2000/0),故往返无量化损失。若日后加入非格点值,需先给 `UI.spin` 调步长。 + +- [ ] **Step 4: 提交** + +```bash +git add addons/game_designer/attribute_tab.gd addons/game_designer/attribute_tab.gd.uid addons/game_designer/designer_panel.gd +git commit -F- <<'EOF' +feat(attr): 游戏设计器新增「属性」页,字段化编辑 attributes.json + +按 CLAUDE.md 编辑器插件规范:控件经 designer_ui 工厂创建、标签中英双语中文在前、 +combine 下拉存 key 不存显示串、_save 合并式写回防丢键、UI 在 _ready 而非 _init 构建。 + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 5: 运行时验收 + 收尾 + +**Files:** 无(运行时断言)+ `docs/design/numerical_design.md` + 路线图 + +- [ ] **Step 1: 启动战斗场景** + +Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。`get_editor_errors` 应无本项目脚本相关报错。 + +- [ ] **Step 2: `cpu_limit` 生效 + 现有平衡零改动(验收 7、8)** + +> `combat_manager` 的换杖入口是 `_CORE_ROSTER` 轮换(商店「法杖」按钮)。本步直接调 `PlayerStats` 与 `SpellEvaluator` 的公开状态验证,避免依赖 UI。 + +Run: godot-mcp-pro `execute_game_script`: +```gdscript +var fails := 0 +var log := [] +# 直接模拟 _rebuild_wand 对 "core" 来源的处理,逐个法杖验证生效 cpu_limit 与 MAX_OPS +for pair in [["wand_basic", 5], ["wand_fast", 3], ["matrix_board", 8]]: + var core = WandPreset.make_core_by_id(pair[0]) + PlayerStats.remove_modifiers_from("core") + PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core") + var eff: int = PlayerStats.cpu_limit + var max_ops: int = SpellEvaluator.MAX_OPS_PER_CPU * eff + if eff != int(pair[1]): + fails += 1; log.append("%s eff=%d want=%d" % [pair[0], eff, pair[1]]) + log.append("%s cpu=%d MAX_OPS=%d" % [pair[0], eff, max_ops]) +# 换杖不累积:连续换两把后应等于最后一把,而非累加 +PlayerStats.remove_modifiers_from("core") +PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core") +PlayerStats.remove_modifiers_from("core") +PlayerStats.add_modifier("cpu_limit", "flat", 8.0, "core") +if PlayerStats.cpu_limit != 8: + fails += 1; log.append("换杖累积 got=%d want=8" % PlayerStats.cpu_limit) +_mcp_print("FAILS=%d %s" % [fails, str(log)]) +``` +Expected: `FAILS=0`,且日志含 `wand_basic cpu=5 MAX_OPS=200`(**验收 8:现有平衡零改动**)、`wand_fast cpu=3 MAX_OPS=120`、`matrix_board cpu=8 MAX_OPS=320`(**验收 7、9**)。 + +- [ ] **Step 3: `move_speed` 数据驱动(验收 10)** + +Run: godot-mcp-pro `execute_game_script`: +```gdscript +var fails := 0 +var base_spd: float = PlayerStats.move_speed +if abs(base_spd - 200.0) > 0.001: fails += 1 # 基准来自 attributes.json +PlayerStats.add_modifier("move_speed", "pct", 0.5, "test") +if abs(PlayerStats.move_speed - 300.0) > 0.001: fails += 1 # 200 × 1.5 +PlayerStats.add_modifier("move_speed", "flat", 100.0, "test") +if abs(PlayerStats.move_speed - 450.0) > 0.001: fails += 1 # (200+100) × 1.5 +PlayerStats.remove_modifiers_from("test") +if abs(PlayerStats.move_speed - 200.0) > 0.001: fails += 1 # 撤销后回到基准 +_mcp_print("FAILS=%d spd=%.1f" % [fails, PlayerStats.move_speed]) +``` +Expected: `FAILS=0 spd=200.0` + +- [ ] **Step 4: `cast_delay_mod` 生效(验收 11)** + +Run: godot-mcp-pro `execute_game_script`: +```gdscript +var pm = get_tree().root.find_child("PlayerManager", true, false) +if pm == null: pm = PlayerManager +var core = WandPreset.make_core_by_id("wand_basic") # cast_interval 0.5 +PlayerStats.remove_modifiers_from("test") +PlayerStats.add_modifier("cast_delay_mod", "pct", 0.5, "test") # inverse: ×0.5 +pm.equip_wand(core, null) +var got: float = pm._cast_interval +PlayerStats.remove_modifiers_from("test") +_mcp_print("FAILS=%d interval=%.4f want=0.2500" % [(0 if abs(got - 0.25) < 0.001 else 1), got]) +``` +Expected: `FAILS=0 interval=0.2500` +> 适配注:若 `equip_wand(core, null)` 因 `compiled` 为 null 报错,改传 `SpellEvaluator.compile_wand(core, [])` 的产物;核心断言是 `_cast_interval == core.cast_interval × cast_delay_mod`。 + +- [ ] **Step 5: 多来源互不干扰(验收 12)** + +Run: godot-mcp-pro `execute_game_script`: +```gdscript +PlayerStats.remove_modifiers_from("core") +PlayerStats.remove_modifiers_from("shopC") +PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core") +PlayerStats.add_modifier("cpu_limit", "flat", 3.0, "shopC") +var both: int = PlayerStats.cpu_limit # 8 +PlayerStats.remove_modifiers_from("core") +var only_shop: int = PlayerStats.cpu_limit # 3(他源仍在) +PlayerStats.remove_modifiers_from("shopC") +var none: int = PlayerStats.cpu_limit # 0 +var fails := (0 if both == 8 else 1) + (0 if only_shop == 3 else 1) + (0 if none == 0 else 1) +_mcp_print("FAILS=%d both=%d only_shop=%d none=%d" % [fails, both, only_shop, none]) +``` +Expected: `FAILS=0 both=8 only_shop=3 none=0` + +- [ ] **Step 6: 孤儿字段零残留 + 存档往返(验收 13)** + +Run: godot-mcp-pro `stop_scene`,然后 `execute_editor_script`: +```gdscript +var fails := 0 +var hits := [] +for p in ["res://scripts/autoloads/player_stats.gd", "res://scripts/domain/player_manager.gd", + "res://scripts/domain/combat/combat_manager.gd", "res://scripts/domain/spell_system/spell_evaluator.gd"]: + var s := FileAccess.get_file_as_string(p) + if s.contains("PlayerStats.armor") or s.contains("PlayerStats.resistance"): fails += 1; hits.append(p) + if s.contains("var armor") or s.contains("var resistance"): fails += 1; hits.append(p + ":decl") + if s.contains("MOVE_SPEED"): fails += 1; hits.append(p + ":MOVE_SPEED") +# 存档不再含派生字段 +var ps := FileAccess.get_file_as_string("res://scripts/autoloads/player_stats.gd") +if ps.contains('"hp_max": hp_max') or ps.contains('"cpu_limit": cpu_limit'): fails += 1; hits.append("save_derived") +_mcp_print("FAILS=%d %s" % [fails, str(hits)]) +``` +Expected: `FAILS=0 []` +> 另跑全项目 grep 复核 `armor` / `resistance` 在 `scripts/` 下只剩 `EnemyManager` 的敌人侧用法(敌人 armor 与元素抗性是另一套,**不受本次影响**)。 + +- [ ] **Step 7: 订正权威文档 + 路线图** + +- `docs/design/numerical_design.md` §1.1:`move_speed` 基准 **300 → 200**,并在该行「说明」列注明:`⚠️ 2026-07-31 订正:原写 300 从未被任何代码读取;实际实现自 S0 起为 200,20 波内容与 Boss 弹幕密度均按此值调校,故以既成事实为准。见 docs_dev/specs/2026-07-31-player-attributes-design.md §2.2。` +- 同表 `cpu_limit` 行:删去或更新那条 `⚠️实现现状(2026-07-20):代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit(待修,本设计更优)` —— 改为已修复,并注明生效值 = 玩家(基准 0) + 法杖(`cores.json` 3–8)。 +- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`:E3 切分列表第 1 项「属性词条系统」勾除并标注**实际范围**(只做框架 + 三条死线,`attunement_×4`/`luck`/`recharge_speed_mod` 另行立项),格式对齐 E1 已完成项。同时订正 E3「现状」里「`player_stats.gd` 有 `resistance` 等占位属性(4/5 stats 未接线)」这句 —— 它与本次查明的事实不符(权威属性表是 11 个,`armor`/`resistance` 根本不在表内)。 + +```bash +git add docs/design/numerical_design.md docs_dev/plans/2026-07-23-missing-features-roadmap.md +git commit -F- <<'EOF' +docs: 订正 move_speed 权威值 300→200 与 cpu_limit 已修复;路线图 E3-① 勾除 + +move_speed 300 从未被任何代码读取,200 自 S0 沿用并已围绕它调校 20 波内容, +以既成事实为准。cpu_limit 的「从不读」告警随本次接线解除。 +路线图 E3 现状原写「4/5 stats 未接线」与事实不符——权威属性表是 11 个, +armor/resistance 根本不在表内,一并订正。 + +Co-Authored-By: Claude Opus 5 +EOF +``` + +- [ ] **Step 8: 合并决策** + +用 `superpowers:finishing-a-development-branch` 决定分支去向。前四个已完成子项(i-frames / 抗性 / 弹跳 / 归航)均合并进 `master` 并删分支。 + +--- + +## 验收标准回溯(spec §4) + +| # | 验收标准 | 覆盖 | 观测方式 | +| :- | :-- | :-- | :-- | +| 1 | `hybrid` 连乘 ×1.728 非 ×1.6 | Task 1 Step 3 ① | 纯函数断言 | +| 2 | `hybrid` 混合 (200+50)×1.2=300 | Task 1 Step 3 ② | 纯函数断言 | +| 3 | `inverse` 0.512 + 下限钳制 | Task 1 Step 3 ③③b | 纯函数断言 | +| 4 | `add_int` 拒 `pct`,`flat` 仍累加 | Task 1 Step 3 ④ | 纯函数断言 | +| 5 | `hard` 两方向 + `hard=0` 不钳制 | Task 1 Step 3 ⑤⑤b⑥c | 纯函数断言 | +| 6 | 空列表 → base;未知 `combine` 回退 | Task 1 Step 3 ⑥⑥b | 纯函数断言 | +| 7 | `cores.json` `cpu_limit` 生效 | Task 5 Step 2 | 运行时 | +| 8 | 现有平衡零改动(小木仍 200 步) | Task 5 Step 2 | 运行时 | +| 9 | 换杖不累积(5→8 得 8 非 13) | Task 5 Step 2 | 运行时 | +| 10 | `move_speed` 数据驱动 + 加成生效 | Task 5 Step 3 | 运行时 | +| 11 | `cast_delay_mod` 使间隔减半 | Task 5 Step 4 | 运行时 | +| 12 | `remove_modifiers_from` 不误删他源 | Task 5 Step 5 | 运行时 | +| 13 | 孤儿字段零残留 + 存档不含派生值 | Task 5 Step 6 | 源码断言 | +| 14 | 设计器往返一致 + `combine` 存 key | Task 4 Step 4 | 绕缓存实例化 |