Files
spellforge/docs_dev/plans/2026-07-31-player-attributes.md
T
joywayerandClaude Opus 5 61445caad7 docs(plan): Task4 补两条约束——不做热重载 + inverse 的 hard 语义相反需在 UI 体现
remove_modifiers_from 在无命中时提前返回,不是强制重新同步原语,故热重载不能
指望它;本页与其它设计器页一致,保存后提示重启生效即可。

inverse 下 hard 是下限、0 表示钳到 0,与 hybrid/add_int 的「0 = 不钳制」相反,
设计师填 0 期待「不限制」会得到相反结果,提示文案需体现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:32:01 +08:00

755 lines
40 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`。
> ② **验证 `class_name` 脚本的首选手段(Task 1 实测得出,优于下面两种绕法)**:
> ```gdscript
> var S = ResourceLoader.load(path, "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
> ```
> 强制绕过引擎缓存、从磁盘重新编译,**保留 `class_name` 原样**且走引擎自己的编译器 —— 因此它顺带证明了静态类型标注(如 `Dictionary[String, Combine]`、`for m: Dictionary`)真能被引擎编译,而不只是能被动态副本解析。无需 `restart_editor`,也不必改源码。
> ③ **⚠️ 直接调用已注册的全局类名会拿到过时结果,是假通过陷阱。** Task 1 修完文件后经 `AttributeFormula.compute(...)` 直调,返回的仍是**修复前**的数值 —— 编辑器持有的已编译类是旧的。**改完源码后不要用全局类名验证**,用 ② 的 `CACHE_MODE_IGNORE`。
> ④ `GDScript.new()` + `source_code` + `reload()` 这个绕法,在该类被注册为全局类之后会失败(同一条 `hides a global script class`);若要用必须先 `src.replace("class_name AttributeFormula\n", "")` 剥掉那行。`@tool` 编辑器类在 dock 里被缓存,同理。**优先用 ②。**
> ⑤ **`var x := <Variant 方法调用>` 在 4.7.1 是编译错误而非警告**,且报错无行号(`Script compilation failed`),极难定位。断言夹具里若持有动态 `GDScript` 引用 `F`,必须写 `var r: float = F.compute(...)` 而非 `var r := F.compute(...)`。
> ⑥ `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" 的加成接入)
# 0 守卫:cpu_limit=0 意味着所有法术执行零步、全局静默失效。此处是该故障唯一可观测处,
# 能同时兜住上游两种失败(attributes.json 缺失 → 裸字段停在 0add_modifier 的 attr_id 打错 →
# "core" 份额没注入),而根因处的报错够不到这两种。
if PlayerStats.cpu_limit <= 0:
push_error("SpellEvaluator: PlayerStats.cpu_limit=%d,法术将无法执行;检查 attributes.json 与 _rebuild_wand 的 core 加成注入" % PlayerStats.cpu_limit)
var max_ops: int = MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)
```
> **为什么在这里也守一道**(Task 2 代码质量评审建议):`cpu_limit = 0` 的后果是「所有法术静默什么都不做」—— 症状离根因极远。而这里是它变得**可观测**的地方,一条 `push_error` 就能指向真正该查的两处。`maxi(..., 1)` 使游戏在故障下仍可玩(退化为 40 步)而非完全瘫痪。
- [ ] **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 不存显示串**。
>
> **不要做热重载**(Task 2 评审前瞻提醒):本页只编辑并保存 JSON,**不**尝试让运行中的 `PlayerStats` 立即生效 —— 与其它设计器页一致(保存后提示「重启 F5 生效」)。若日后真要加热重载,注意 `remove_modifiers_from` **不是**强制重新同步的原语(无命中时提前返回),必须自己调 `_load_attr_definitions()` + `_recompute_attrs()`。
>
> **`inverse` 的 `hard` 语义与其它两种相反**(Task 1 实现者发现):`hybrid`/`add_int` 下 `hard=0` 表示「不钳制」,而 `inverse` 下 `hard` 是**下限**、`0` 表示「钳到 0」。设计器里编辑 `hard` 时应在提示文案中体现这个差异,否则设计师给 `inverse` 属性填 0 期待「不限制」会得到相反结果。
- [ ] **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: 回填本计划为实际执行版**
实现过程中若因评审而改动了代码或断言,**本计划里对应的代码块与脚本必须回填为实际落地的版本**,并勾上各任务的复选框。否则任何人按这份计划复跑,得到的是过时代码和可能已失效的断言 —— 归航那期就踩过这个坑。
已知必须回填的(Task 1 评审后代码有变):
- Task 1 Step 2 的实现代码块 → 加上 `pct` 越界守卫、`_COMBINE_NAMES``_COMBINE_BY_NAME``ri``floored``Dictionary[String, Combine]``for m: Dictionary` 的静态类型、以及 `hard` 语义差异的注释。
- Task 1 Step 3 的夹具 → 改用工具坑 ② 的 `ResourceLoader.load(..., CACHE_MODE_IGNORE)``cases` 补上 ⑦/⑦b 两条越界断言(共 12 条);注意工具坑 ⑤(`var r: float =` 而非 `var r :=`)。
其余任务同理:**以实际跑通的为准**,并在每处回填旁注明「原写法为何不可用」。
- [ ] **Step 9: 合并决策**
`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 | 绕缓存实例化 |