remove_modifiers_from 在无命中时提前返回,不是强制重新同步原语,故热重载不能 指望它;本页与其它设计器页一致,保存后提示重启生效即可。 inverse 下 hard 是下限、0 表示钳到 0,与 hybrid/add_int 的「0 = 不钳制」相反, 设计师填 0 期待「不限制」会得到相反结果,提示文案需体现。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
40 KiB
玩家属性系统(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属此类;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 实测得出,优于下面两种绕法):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:332var max_ops: int = MAX_OPS_PER_CPU * 5(MAX_OPS_PER_CPU = 40,:8)。player_manager.gd:7const 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 规定主分支上不做功能开发。
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:
_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:
## 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:
# 绕开 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:
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: 提交
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
{
"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,改为:
# ── 属性(生效值:静态类型裸字段,读取端零开销)─────────────
# 由 _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)开头加载定义并首次重算(必须早于任何存档回读):
func _ready() -> void:
_load_attr_definitions()
_recompute_attrs()
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
在文件末尾新增:
# ── 属性框架 ──────────────────────────────────────────────
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)改为:
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:
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: 提交
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 的
var max_ops: int = MAX_OPS_PER_CPU * 5
改为
# 生效 cpu_limit 已含法杖份额(combat_manager 以 source="core" 的加成接入)
# 0 守卫:cpu_limit=0 意味着所有法术执行零步、全局静默失效。此处是该故障唯一可观测处,
# 能同时兜住上游两种失败(attributes.json 缺失 → 裸字段停在 0;add_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 的
_velocity = dir * MOVE_SPEED
改为
_velocity = dir * PlayerStats.move_speed
- Step 3: 施法间隔乘
cast_delay_mod
player_manager.gd 的 equip_wand(:85),把 :88 的
_cast_interval = core.cast_interval if core else 0.5
改为
_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)之后追加:
# 法杖运算力以加成来源接入(非调用点相加),使 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: 提交
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:
## 属性编辑器标签 — 玩家属性的 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 的「⚖ 平衡」之后)追加:
_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 源码文本 —— 那不是往返):
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: 提交
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:
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:
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:
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:
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:
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.json3–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根本不在表内)。
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 | 绕缓存实例化 |