561 lines
22 KiB
Markdown
561 lines
22 KiB
Markdown
# 法术编辑器并入 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 断言已相应放宽(不判缺失键为失败)。
|