Files
spellforge/docs_dev/plans/2026-07-31-player-attributes.md
T
joywayerandClaude Opus 5 cd3618c066 docs(plan): 补 Step3b 断言 push_error + 订正绕缓存法在类注册后失效
Task 1 评审实测发现两处计划缺陷:
① 断言 ④ 的数字部分对「add_int 拒 pct」是空的——删掉整个拒绝块 ④ 仍返回 13.0,
   因为 ADD_INT 的 match 分支不读 pct_prod。拒绝行为唯一可观测证据是 push_error,
   补 Step 3b 用唯一标记值 + get_editor_errors 单独断言。
② GDScript.new()+source_code+reload() 在该类被注册为全局类之后同样报
   hides a global script class——Task 1 首次运行因尚未注册才侥幸通过,
   后续任务照抄会失败。改为先剥离 class_name 行,或直接调已注册的全局类。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 10:53:17 +08:00

730 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 玩家属性系统(Player AttributesImplementation 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` 属此类;`player_stats.gd` / `player_manager.gd` / `spell_evaluator.gd` / `combat_manager.gd` / `attribute_tab.gd` / `designer_panel.gd` 无 `class_name`,可正常 `validate_script`。
> ② **`GDScript.new()` + `source_code` + `reload()` 这个绕法,在该类被引擎注册为全局类之后同样会失败**(同一条 `hides a global script class`)。Task 1 首次运行时因尚未注册而侥幸通过,**后续任务不能照抄**。正确做法二选一:
> - **剥离 `class_name` 行再 `reload()`**`S.source_code = src.replace("class_name AttributeFormula\n", "")``@tool` 编辑器类同理剥自己的 `class_name`);
> - 或**直接调用已注册的全局类**`AttributeFormula.compute(...)`(只验证已落盘版本时更省事)。
> ③ `@tool` 编辑器类在 dock 里被缓存,`load(...).new()` 拿到旧类,绕法同 ②。
> ④ `execute_editor_script` 静默阻止运行时 `FileAccess.WRITE`。
> ⑤ 新增 `.gd` 后 `.gd.uid` 不会自动出现,需 `EditorInterface.get_resource_filesystem().scan()` 触发扫描才生成;项目约定二者一并提交。
> **关键既有事实**`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
## 返回统一为 floatadd_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 []`
> ⚠️ **断言 ④ 的数字部分对「拒绝 `pct`」是空的**(Task 1 评审实测发现):把整个拒绝块删掉,④ 仍返回 13.0 —— 因为 `ADD_INT` 的 match 分支根本不读 `pct_prod`。数字只证明了「`pct` 之后的 `flat` 仍能累加」。**拒绝行为唯一的可观测证据是 `push_error`**,必须单独断言,否则意图②无人守卫。
- [ ] **Step 3b: 断言 `push_error` 真的发出(补 ④ 的空缺)**
用**唯一标记值**发起调用,再从编辑器错误日志里找它 —— 标记值确保读到的不是历史遗留日志。
Run: godot-mcp-pro `clear_output`,然后 `execute_editor_script`
```gdscript
var S := GDScript.new()
# ⚠️ 必须剥掉 class_name:该类此时已被引擎注册为全局类,带 class_name 的内存副本会报
# "hides a global script class"Task 1 首次运行时尚未注册才侥幸通过)
S.source_code = FileAccess.get_file_as_string("res://scripts/domain/attribute_formula.gd") \
.replace("class_name AttributeFormula\n", "")
if S.reload() != OK:
_mcp_print("PARSE_FAIL"); return
# 三条各带唯一标记,逐一触发报错路径
S.compute(0.0, [{"mode":"pct","value":0.123456}], {"combine":"add_int","hard":50.0})
S.compute(1.0, [], {"combine":"zzzprobe9182","hard":0.0})
S.compute(1.0, [{"mode":"zzzmode9182","value":1.0}], {"combine":"hybrid","hard":0.0})
_mcp_print("probes_fired")
```
再 Run: godot-mcp-pro `get_editor_errors`,断言三条标记均出现:
- `add_int 属性不接受 pct 加成(value=0.123456`
- `未知 combine「zzzprobe9182」`
- `未知 mode「zzzmode9182」`
Expected: 三条全部命中。**任一缺失即说明对应的报错路径未执行** —— 那是真缺陷,不是日志问题。
- [ ] **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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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` 38)。
- `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 <noreply@anthropic.com>
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 | 绕缓存实例化 |