Files
spellforge/docs_dev/plans/2026-07-21-spell-editor-fieldize.md
T

561 lines
22 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.
# 法术编辑器并入 game_designer + 字段化表单 — 实现计划
> **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:** 把法术编辑器从独立插件并入 game_designer 底部面板,并用按类型动态切换的字段化表单替代原始 JSON meta。
**Architecture:** 新建 `addons/game_designer/spell_tab.gd`(对齐 `core_tab.gd` 风格,复用 `designer_ui.gd`),用一张 schema 表按 `类型 + action_kind/logic_op 子选择` 动态重建 meta 输入控件;保留「其它(JSON)」逃生舱防丢未知键;可选键默认值时不写出。`designer_panel.gd` 改指新 tab,删除独立 `spell_editor` 插件。运行时代码零改动。
**Tech Stack:** Godot 4.6 (Mono) / GDScript `@tool` EditorPlugin;数据文件 `res://data/spells.json`;验证用 godot-mcp-pro`validate_script` / `execute_editor_script` / `get_editor_errors`)。
**Spec:** `docs_dev/specs/2026-07-21-spell-editor-fieldize-design.md`
**验证说明(TDD 适配):** 本项目编辑器 tab 无单元测试框架(`core_tab.gd` 等均无测试),故沿用既有模式——以 godot-mcp-pro 的 `execute_editor_script` 对真实 `spells.json` 做 22 条**回读往返集成测试**作为主验证门(Task 3),先于接线与删除。所有涉及 Godot 编辑器的验证步骤要求 Godot 编辑器已开启且 `godot_mcp` 插件在线。
---
### Task 1: 创建 `spell_tab.gd`(完整字段化表单)
**Files:**
- Create: `addons/game_designer/spell_tab.gd`
- [ ] **Step 1: 写入完整脚本**
创建 `addons/game_designer/spell_tab.gd`,内容如下(完整、可直接运行):
```gdscript
## 法术编辑器标签(game_designer)— 字段化表单,替代原始 JSON meta。
## 数据持久化到 res://data/spells.json,运行时由 SpellRegistry 加载。
## 枚举标签来源:DamageType(scripts/autoloads/damage_type.gd)、StatusID(scripts/autoloads/status_id.gd)
## 若域枚举新增值,需同步 DMG_LABELS / ST_LABELS。
@tool
extends VBoxContainer
const UI = preload("res://addons/game_designer/designer_ui.gd")
const PATH := "res://data/spells.json"
const TYPE_NAMES := ["ACTION", "MODIFIER", "TRIGGER", "LOGIC"]
const ACTION_KINDS := ["弹道", "zone 毒池", "summon 召唤"] # 0=默认(不写 action_kind) 1=zone 2=summon
const LOGIC_OPS := ["every_n_shots", "if_hp_below", "if_enemy_nearby", "loop"]
const DMG_LABELS := ["物理", "火", "冰", "雷", "毒", "奥术"] # index = damage_type 整数
const ST_LABELS := ["无", "燃烧", "冻结", "中毒", "潮湿", "沾油", "眩晕", "连击", "易伤"] # index = status 整数
var _data: Dictionary = {}
var _cur: String = ""
var _list: ItemList
var _id: LineEdit
var _type: OptionButton
var _name: LineEdit
var _desc: LineEdit
var _tags: LineEdit
var _mana: SpinBox
var _shop: SpinBox
var _unlock: SpinBox
var _akind_row: HBoxContainer
var _akind: OptionButton
var _lop_row: HBoxContainer
var _lop: OptionButton
var _dyn: VBoxContainer
var _extra: TextEdit
var _status: Label
var _field_ctrls: Dictionary = {} # key -> control(当前动态字段)
func _ready() -> void:
add_child(UI.header("✨ 法术编辑器 — 字段化表单"))
_load()
_list = ItemList.new()
_list.custom_minimum_size = Vector2(0, 110)
_list.item_selected.connect(_on_select)
add_child(_list)
var brow := HBoxContainer.new(); add_child(brow)
brow.add_child(UI.btn("➕新增", _on_new))
brow.add_child(UI.btn("⧉复制", _on_dup))
brow.add_child(UI.btn("🗑删除", _on_delete))
add_child(HSeparator.new())
_id = _row("ID(唯一)", UI.line(""))
_type = _row("类型", UI.opt(TYPE_NAMES, 0))
_type.item_selected.connect(_on_type_or_sub_changed)
_name = _row("显示名", UI.line("", "tr 键或文本"))
_desc = _row("描述", UI.line("", "tr 键或文本"))
_tags = _row("元素标签", UI.line("", "逗号分隔,如 tag:fire"))
add_child(HSeparator.new())
_mana = _row("蓝耗 mana_cost", UI.spin(0, 100000, 1, 0))
_shop = _row("商店价 shop_cost", UI.spin(0, 100000, 1, 20))
_unlock = _row("解锁价 unlock_cost", UI.spin(0, 100000, 1, 0))
_akind = UI.opt(ACTION_KINDS, 0)
_akind.item_selected.connect(_on_type_or_sub_changed)
_akind_row = _labeled_row("动作种类", _akind)
_lop = UI.opt(LOGIC_OPS, 0)
_lop.item_selected.connect(_on_type_or_sub_changed)
_lop_row = _labeled_row("逻辑 logic_op", _lop)
add_child(Label.new())
_dyn = VBoxContainer.new(); add_child(_dyn)
var el := Label.new(); el.text = "其它(JSON)"; add_child(el)
_extra = TextEdit.new(); _extra.custom_minimum_size = Vector2(0, 44)
_extra.placeholder_text = "{}(schema 之外的键,保留不丢)"
add_child(_extra)
add_child(HSeparator.new())
var srow := HBoxContainer.new(); add_child(srow)
srow.add_child(UI.btn("✓应用到列表", _on_apply))
srow.add_child(UI.btn("💾保存文件", _save))
srow.add_child(UI.btn("↻重载", func(): _load(); _refresh(); UI.set_status(_status, "已重载")))
_status = UI.status_label(); add_child(_status)
_refresh()
_rebuild_dynamic()
func _row(label: String, ctrl: Control) -> Control:
var h := HBoxContainer.new()
h.add_child(UI.cell_label(label))
h.add_child(ctrl)
add_child(h)
return ctrl
func _labeled_row(label: String, ctrl: Control) -> HBoxContainer:
var h := HBoxContainer.new()
h.add_child(UI.cell_label(label))
h.add_child(ctrl)
add_child(h)
return h
# ── 数据 ───────────────────────────────
func _load() -> void:
var d = UI.load_json(PATH)
_data = d if d is Dictionary else {}
func _refresh() -> void:
_list.clear()
var ids: Array = _data.keys(); ids.sort()
for sid in ids:
var t := int(_data[sid].get("type", 0))
_list.add_item("[%s] %s" % [TYPE_NAMES[clampi(t, 0, 3)], sid])
_list.set_item_metadata(_list.item_count - 1, sid)
# ── schema ─────────────────────────────
func _dynamic_schema() -> Array:
match _type.selected:
0:
match _akind.selected:
1: return [
{"k": "radius", "l": "半径 radius", "w": "f", "d": 200.0},
{"k": "status_id", "l": "状态 status_id", "w": "st", "d": 0},
{"k": "duration", "l": "持续 duration", "w": "f", "d": 5.0},
{"k": "tick_interval", "l": "tick 间隔", "w": "f", "d": 1.0}]
2: return [
{"k": "lifetime", "l": "存活 lifetime", "w": "f", "d": 20.0},
{"k": "range", "l": "射程 range", "w": "f", "d": 350.0},
{"k": "fire_interval", "l": "开火间隔", "w": "f", "d": 0.8},
{"k": "base_damage", "l": "伤害 base_damage", "w": "f", "d": 4.0},
{"k": "speed", "l": "速度 speed", "w": "f", "d": 360.0},
{"k": "damage_type", "l": "伤害类型", "w": "dmg", "d": 0}]
_: return [
{"k": "base_damage", "l": "伤害 base_damage", "w": "f", "d": 5.0},
{"k": "speed", "l": "速度 speed", "w": "f", "d": 350.0},
{"k": "lifetime", "l": "存活 lifetime", "w": "f", "d": 4.0},
{"k": "radius", "l": "半径 radius", "w": "f", "d": 6.0},
{"k": "damage_type", "l": "伤害类型", "w": "dmg", "d": 0},
{"k": "apply_status_id", "l": "施加状态", "w": "st", "d": 0, "opt": true},
{"k": "apply_combo_mark", "l": "连击标记", "w": "b", "d": false, "opt": true},
{"k": "pierce", "l": "穿透 pierce", "w": "i", "d": 0, "opt": true}]
1: return [
{"k": "damage_add", "l": "伤害+ damage_add", "w": "f", "d": 0.0, "opt": true},
{"k": "damage_mult", "l": "伤害× damage_mult", "w": "f", "d": 1.0, "opt": true},
{"k": "spread_add", "l": "多发 spread_add", "w": "i", "d": 0, "opt": true},
{"k": "multicast", "l": "额外施法 multicast", "w": "i", "d": 0, "opt": true},
{"k": "pierce", "l": "穿透 pierce", "w": "i", "d": 0, "opt": true},
{"k": "heavy_cost", "l": "扣血 heavy_cost", "w": "i", "d": 0, "opt": true},
{"k": "mana_leech", "l": "击杀回蓝 mana_leech", "w": "i", "d": 0, "opt": true}]
2: return [
{"k": "base_damage", "l": "伤害 base_damage", "w": "f", "d": 3.0},
{"k": "speed", "l": "速度 speed", "w": "f", "d": 250.0},
{"k": "lifetime", "l": "存活 lifetime", "w": "f", "d": 4.0},
{"k": "radius", "l": "半径 radius", "w": "f", "d": 6.0},
{"k": "damage_type", "l": "伤害类型", "w": "dmg", "d": 0}]
3:
match _lop.selected:
0: return [
{"k": "n", "l": "n(每 n 次)", "w": "i", "d": 3},
{"k": "reg", "l": "reg(寄存器)", "w": "i", "d": 0, "opt": true}]
1: return [
{"k": "threshold", "l": "阈值 threshold", "w": "f", "d": 0.5, "min": 0.0, "max": 1.0}]
2: return [
{"k": "range", "l": "范围 range", "w": "f", "d": 200.0}]
3: return [
{"k": "count", "l": "循环次数 count", "w": "i", "d": 2}]
return []
func _rebuild_dynamic() -> void:
_akind_row.visible = _type.selected == 0
_lop_row.visible = _type.selected == 3
for c in _dyn.get_children():
c.queue_free()
_field_ctrls.clear()
for f in _dynamic_schema():
var ctrl := _make_ctrl(f)
_field_ctrls[f["k"]] = ctrl
var h := HBoxContainer.new()
h.add_child(UI.cell_label(f["l"]))
h.add_child(ctrl)
_dyn.add_child(h)
func _make_ctrl(f: Dictionary) -> Control:
match f["w"]:
"i": return UI.spin(f.get("min", 0), f.get("max", 100000), 1, f["d"])
"f": return UI.spin(f.get("min", 0.0), f.get("max", 100000.0), 0.05, f["d"])
"b":
var cb := CheckBox.new(); cb.button_pressed = bool(f["d"]); return cb
"dmg": return UI.opt(DMG_LABELS, int(f["d"]))
"st": return UI.opt(ST_LABELS, int(f["d"]))
return UI.line("")
func _get_ctrl(ctrl: Control):
if ctrl is SpinBox: return (ctrl as SpinBox).value
if ctrl is CheckBox: return (ctrl as CheckBox).button_pressed
if ctrl is OptionButton: return (ctrl as OptionButton).selected
return null
func _set_ctrl(ctrl: Control, v) -> void:
if ctrl is SpinBox: (ctrl as SpinBox).value = float(v)
elif ctrl is CheckBox: (ctrl as CheckBox).button_pressed = bool(v)
elif ctrl is OptionButton: (ctrl as OptionButton).selected = int(v)
# ── 选中 → 填表 ─────────────────────────
func _on_select(idx: int) -> void:
_cur = String(_list.get_item_metadata(idx))
var d: Dictionary = _data.get(_cur, {})
var meta: Dictionary = d.get("meta", {})
_id.text = _cur
_type.selected = clampi(int(d.get("type", 0)), 0, 3)
_name.text = String(d.get("display_name", ""))
_desc.text = String(d.get("description", ""))
_tags.text = ",".join(d.get("element_tags", []))
var ak := String(meta.get("action_kind", ""))
_akind.selected = (1 if ak == "zone" else (2 if ak == "summon" else 0))
_lop.selected = maxi(0, LOGIC_OPS.find(String(meta.get("logic_op", "every_n_shots"))))
_mana.value = float(meta.get("mana_cost", 0))
_shop.value = float(meta.get("shop_cost", 20))
_unlock.value = float(meta.get("unlock_cost", 0))
_rebuild_dynamic()
for k in _field_ctrls:
if meta.has(k):
_set_ctrl(_field_ctrls[k], meta[k])
_extra.text = _compute_extra(meta)
func _known_keys() -> Dictionary:
var ks := {"mana_cost": true, "shop_cost": true, "unlock_cost": true,
"action_kind": true, "logic_op": true}
for f in _dynamic_schema():
ks[f["k"]] = true
return ks
func _compute_extra(meta: Dictionary) -> String:
var known := _known_keys()
var leftover := {}
for k in meta:
if not known.has(k):
leftover[k] = meta[k]
return "" if leftover.is_empty() else JSON.stringify(leftover, " ")
func _on_type_or_sub_changed(_i: int = 0) -> void:
_rebuild_dynamic()
# ── 读表 → meta ─────────────────────────
func _read_meta() -> Variant:
var meta := {}
meta["mana_cost"] = int(_mana.value)
meta["shop_cost"] = int(_shop.value)
if int(_unlock.value) > 0:
meta["unlock_cost"] = int(_unlock.value)
if _type.selected == 0 and _akind.selected == 1:
meta["action_kind"] = "zone"
elif _type.selected == 0 and _akind.selected == 2:
meta["action_kind"] = "summon"
if _type.selected == 3:
meta["logic_op"] = LOGIC_OPS[_lop.selected]
for f in _dynamic_schema():
var ctrl: Control = _field_ctrls.get(f["k"])
if ctrl == null: continue
var v = _get_ctrl(ctrl)
if f["w"] == "i" or f["w"] == "dmg" or f["w"] == "st":
v = int(v)
elif f["w"] == "f":
v = float(v)
if f.get("opt", false) and _is_default(v, f["d"]):
continue
meta[f["k"]] = v
var extra_txt := _extra.text.strip_edges()
if extra_txt != "":
var parsed = JSON.parse_string(extra_txt)
if not (parsed is Dictionary):
return null
for k in parsed:
if not meta.has(k):
meta[k] = parsed[k]
return meta
func _is_default(v, d) -> bool:
if v is float or d is float:
return absf(float(v) - float(d)) < 0.0001
return v == d
# ── 增删改查 ────────────────────────────
func _on_apply() -> void:
var sid := _id.text.strip_edges()
if sid == "":
UI.set_status(_status, "✗ ID 不能为空", true); return
var meta = _read_meta()
if not (meta is Dictionary):
UI.set_status(_status, "✗ 其它(JSON) 不是合法对象", true); return
var tags: Array = []
for t in _tags.text.split(",", false):
var s := t.strip_edges()
if s != "": tags.append(s)
if _cur != "" and _cur != sid and _data.has(_cur):
_data.erase(_cur)
_data[sid] = {"type": _type.selected, "display_name": _name.text,
"description": _desc.text, "element_tags": tags, "meta": meta}
_cur = sid
_refresh()
UI.set_status(_status, "✓ 已应用『%s』(记得保存)" % sid)
func _on_new() -> void:
var sid := "new_spell"; var i := 1
while _data.has(sid): sid = "new_spell_%d" % i; i += 1
_data[sid] = {"type": 0, "display_name": sid, "description": "",
"element_tags": [], "meta": {"base_damage": 5.0, "speed": 350.0,
"lifetime": 4.0, "radius": 6.0, "damage_type": 0, "mana_cost": 5, "shop_cost": 20}}
_refresh(); _select_id(sid)
func _on_dup() -> void:
if _cur == "" or not _data.has(_cur): return
var sid := _cur + "_copy"; var i := 1
while _data.has(sid): sid = "%s_copy%d" % [_cur, i]; i += 1
_data[sid] = _data[_cur].duplicate(true)
_refresh(); _select_id(sid)
func _on_delete() -> void:
if _cur != "" and _data.has(_cur):
_data.erase(_cur); _cur = ""; _refresh()
UI.set_status(_status, "已删除(记得保存)")
func _save() -> void:
if UI.save_json(PATH, _data):
UI.set_status(_status, "💾 已保存 %d 条法术(F5 生效)" % _data.size())
else:
UI.set_status(_status, "✗ 保存失败", true)
func _select_id(sid: String) -> void:
for i in _list.item_count:
if String(_list.get_item_metadata(i)) == sid:
_list.select(i); _on_select(i); return
```
- [ ] **Step 2: 语法校验**
用 godot-mcp-pro 校验脚本:
调用 `mcp__godot-mcp-pro__validate_script`,参数 `path = "res://addons/game_designer/spell_tab.gd"`
Expected: 校验通过,无语法错误。
- [ ] **Step 3: 提交**
```bash
git add addons/game_designer/spell_tab.gd
git commit -m "feat(spell-editor): 新增 game_designer 法术 tab(字段化表单)"
```
---
### Task 2: 接线 designer_panel + 移除独立 spell_editor 插件
**Files:**
- Modify: `addons/game_designer/designer_panel.gd:12`
- Modify: `project.godot:68``[editor_plugins] enabled`
- Delete: `addons/spell_editor/spell_editor.gd` `addons/spell_editor/spell_editor.gd.uid` `addons/spell_editor/spell_dock.gd` `addons/spell_editor/spell_dock.gd.uid` `addons/spell_editor/plugin.cfg`
- [ ] **Step 1: 改 designer_panel 指向新 tab**
`addons/game_designer/designer_panel.gd` 第 12 行,把:
```gdscript
_add_tab(tabs, "✨ 法术", preload("res://addons/spell_editor/spell_dock.gd").new())
```
改为:
```gdscript
_add_tab(tabs, "✨ 法术", preload("res://addons/game_designer/spell_tab.gd").new())
```
- [ ] **Step 2: 从 project.godot 移除 spell_editor 插件启用项**
`project.godot` `[editor_plugins]` 段,把:
```
enabled=PackedStringArray("res://addons/game_designer/plugin.cfg", "res://addons/godot_mcp/plugin.cfg", "res://addons/spell_editor/plugin.cfg")
```
改为:
```
enabled=PackedStringArray("res://addons/game_designer/plugin.cfg", "res://addons/godot_mcp/plugin.cfg")
```
- [ ] **Step 3: 删除独立插件目录**
```bash
git rm addons/spell_editor/spell_editor.gd addons/spell_editor/spell_editor.gd.uid \
addons/spell_editor/spell_dock.gd addons/spell_editor/spell_dock.gd.uid \
addons/spell_editor/plugin.cfg
```
Expected: 5 个文件被移除(若 `.uid` 不存在则从命令中去掉对应项,用 `git rm` 剩余项)。
- [ ] **Step 4: 编辑器重载校验无残留引用与报错**
调用 `mcp__godot-mcp-pro__reload_project`,再调用 `mcp__godot-mcp-pro__get_editor_errors`
Expected: 无错误;无对 `res://addons/spell_editor/...` 的悬空引用。
- [ ] **Step 5: 提交**
```bash
git add addons/game_designer/designer_panel.gd project.godot
git commit -m "refactor(spell-editor): 并入 game_designer tab,移除独立 spell_editor 插件"
```
---
### Task 3: 22 条法术回读往返集成测试(主验证门)
**Files:**
- 无(一次性 `execute_editor_script`,不落盘)
- [ ] **Step 1: 运行往返断言脚本**
调用 `mcp__godot-mcp-pro__execute_editor_script``code` 为:
```gdscript
var Tab = load("res://addons/game_designer/spell_tab.gd")
var tab = Tab.new()
EditorInterface.get_base_control().add_child(tab)
var raw = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json"))
var fails: Array = []
for sid in raw:
var found := false
for i in tab._list.item_count:
if String(tab._list.get_item_metadata(i)) == sid:
tab._on_select(i); found = true; break
if not found:
fails.append("%s: 未在列表" % sid); continue
var m = tab._read_meta()
if not (m is Dictionary):
fails.append("%s: read_meta 返回 null" % sid); continue
var orig: Dictionary = raw[sid]["meta"]
for k in orig:
if m.has(k):
var a = m[k]; var b = orig[k]
var eq := (absf(float(a) - float(b)) < 0.0001) if (a is float or b is float) else (a == b)
if not eq:
fails.append("%s.%s 值不符 %s != %s" % [sid, k, str(a), str(b)])
for k in m:
if not orig.has(k):
fails.append("%s: 引入多余键 %s=%s" % [sid, k, str(m[k])])
print("ROUNDTRIP FAILS=%d %s" % [fails.size(), str(fails)])
tab.queue_free()
```
Expected: 输出 `ROUNDTRIP FAILS=0 []`
(说明:允许 `orig` 中的默认可选键在 `m` 中被省略——如 `logic_every_n_shots``reg:0`——这属 §4.2 预期简化,脚本不将「缺失」计为失败;只校验存在键值一致 + 不引入多余非默认键。)
- [ ] **Step 2: 若有失败,修复 `spell_tab.gd` 后重跑**
`FAILS>0`:按报错定位是 schema 字段缺失、默认值不符、还是枚举 index 偏移;改 `addons/game_designer/spell_tab.gd``_dynamic_schema()` 或读写逻辑,重跑 Step 1 直到 `FAILS=0`
- [ ] **Step 3: 提交(若有修复)**
```bash
git add addons/game_designer/spell_tab.gd
git commit -m "fix(spell-editor): 修正字段 schema 使 22 条法术回读往返一致"
```
---
### Task 4: 更新手册文档
**Files:**
- Modify: `docs/handbook/06_spell_editor.md`
- [ ] **Step 1: 改「启用/入口」章节**
`## 1. 启用插件` 一节整体替换为「入口」说明:
```markdown
## 1. 打开编辑器
法术编辑现已并入 **游戏设计器**`res://addons/game_designer/`,默认已启用)。
```
Godot 编辑器 → 底部面板 → 🎮 游戏设计器 → ✨ 法术 标签页
```
> 独立的「法术编辑器」左下停靠面板已移除,统一在游戏设计器内维护。
```
- [ ] **Step 2: 更新界面示意图(第 2 节)**
把第 2 节的界面框图替换为字段化表单:
```markdown
## 2. 界面说明
```
✨ 法术编辑器 — 字段化表单
┌────────────────────────┐
│ 法术列表(ItemList
└────────────────────────┘
[➕新增] [⧉复制] [🗑删除]
ID / 类型 / 显示名 / 描述 / 元素标签
── 通用 meta ──
蓝耗 mana_cost / 商店价 shop_cost / 解锁价 unlock_cost
── 动态字段(随类型切换)──
ACTION:动作种类[弹道/zone/summon] + 对应字段(伤害/速度/…枚举下拉)
MODIFIER / TRIGGER / LOGIC(logic_op 子选择) 各自字段
── 其它(JSON) ── ← schema 之外的键,保留不丢
[✓应用到列表] [💾保存文件] [↻重载]
```
```
- [ ] **Step 3: 在第 4 节「meta 字段速查」段首补一句**
`## 4. meta 字段速查(按类型)` 标题下补一行:
```markdown
> 下列字段现由表单按类型自动呈现,无需手写 JSON;本速查作为字段语义参考。`damage_type`(0物理/1火/2冰/3雷/4毒/5奥术) 与 `apply_status_id`/`status_id`(0无/1燃烧/2冻结/3中毒/4潮湿/5沾油/6眩晕/7连击/8易伤) 为整数枚举,表单以下拉呈现。
```
- [ ] **Step 4: 提交**
```bash
git add docs/handbook/06_spell_editor.md
git commit -m "docs(spell-editor): 手册改为字段化表单 + game_designer 入口"
```
---
## Self-Review
**Spec 覆盖:**
- §2 并入 → Task 1(新 tab+ Task 2(接线/删插件)✅
- §3 动态表单(固定/通用/动态区 + 子选择器 + 枚举下拉)→ Task 1 `_dynamic_schema` / `_rebuild_dynamic` / `_make_ctrl`
- §4.1 逃生舱 → Task 1 `_compute_extra` / `_read_meta` extra 合并 ✅
- §4.2 可选键清洁写出 → Task 1 `opt` + `_is_default` + `unlock_cost>0` 判断 ✅
- §4.3 回读验证 → Task 3 往返测试 ✅
- §6 文档 → Task 4 ✅
- §8 验收 1/2(入口唯一、目录删除、无报错)→ Task 2 Step 4;验收 3/4(回读、新增各类型)→ Task 3 ✅
**占位符扫描:** 无 TBD/TODO;所有步骤含完整代码或精确命令。✅
**类型/命名一致性:** `_read_meta`Task 1 定义,Task 3 调用)、`_on_select`/`_list`/`_field_ctrls`/`_dynamic_schema` 跨任务命名一致;`_is_default` 在 Task 1 定义并在 `_read_meta` 使用;Task 3 断言脚本内自带独立 `_eq`(不依赖 tab 私有方法)。✅
**已知取舍:** `logic_every_n_shots``reg:0` 等默认可选键往返后被省略,属 §4.2 预期;Task 3 断言已相应放宽(不判缺失键为失败)。