Files
spellforge/docs_dev/plans/2026-07-22-designer-ui-optimize.md

532 lines
18 KiB
Markdown
Raw Permalink 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.
# 游戏设计器 UI 优化 实现计划
> **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:** 游戏设计器输入框撑满面板、法术元素标签改 5 复选框 + 自定义框、所有枚举下拉统一「中文 English」双语显示。
**Architecture:** 中央改 `designer_ui.gd`(工厂加 `SIZE_EXPAND_FILL` + 新增共享元素词表常量);各 tab 改标签常量为双语并把 spell 元素标签换控件、resonance 元素 A/B 换下拉。存储全部走 index/key,数据文件格式与内容零改动。
**Tech Stack:** Godot 4.6 (Mono) / GDScript `@tool`;验证用 godot-mcp-pro`validate_script` / `execute_editor_script` / `reload_project` / `get_editor_screenshot`)。
**Spec:** `docs_dev/specs/2026-07-22-designer-ui-optimize-design.md`
**验证说明(TDD 适配):** 编辑器 tab 无单元测试框架,沿用既有模式——每个改动文件用 `validate_script` 校验语法;最后用 `execute_editor_script``spells.json`/`resonance.json` 做回读往返断言(Task 6)作为主功能门。所有 MCP 步骤要求 Godot 编辑器开启且 `godot_mcp` 在线;若掉线,聚焦编辑器窗口后重试。
---
### Task 1: `designer_ui.gd` — 布局撑满 + 共享元素词表
**Files:**
- Modify: `addons/game_designer/designer_ui.gd`
- [ ] **Step 1: 用完整内容替换该文件**
`addons/game_designer/designer_ui.gd` 整体替换为(保留 TAB 缩进):
```gdscript
## 游戏设计器通用 UI 辅助(静态方法 + 共享常量,各标签页复用)
@tool
extends RefCounted
## 元素词表(spell_tab 复选框 / resonance_tab 元素下拉共用);label 双语显示,tag 存储,index 对齐
const ELEM_LABELS := ["火 Fire", "冰 Ice", "雷 Lightning", "水 Water", "毒 Poison"]
const ELEM_TAGS := ["tag:fire", "tag:ice", "tag:lightning", "tag:water", "tag:poison"]
static func spin(min_v: float, max_v: float, step: float, val: float) -> SpinBox:
var s := SpinBox.new()
s.min_value = min_v; s.max_value = max_v; s.step = step
s.value = val
s.custom_minimum_size = Vector2(80, 0)
s.size_flags_horizontal = Control.SIZE_EXPAND_FILL
return s
static func line(text: String, placeholder: String = "") -> LineEdit:
var le := LineEdit.new()
le.text = text
le.placeholder_text = placeholder
le.custom_minimum_size = Vector2(120, 0)
le.size_flags_horizontal = Control.SIZE_EXPAND_FILL
return le
static func opt(items: Array, selected: int) -> OptionButton:
var o := OptionButton.new()
for it in items:
o.add_item(str(it))
o.selected = clampi(selected, 0, items.size() - 1)
o.size_flags_horizontal = Control.SIZE_EXPAND_FILL
return o
static func header(text: String) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 15)
l.modulate = Color(1.0, 0.85, 0.4)
return l
static func cell_label(text: String) -> Label:
var l := Label.new()
l.text = text
l.custom_minimum_size = Vector2(70, 0)
return l
static func btn(text: String, cb: Callable) -> Button:
var b := Button.new()
b.text = text
b.pressed.connect(cb)
return b
static func status_label() -> Label:
var l := Label.new()
l.add_theme_font_size_override("font_size", 11)
l.modulate = Color(0.6, 0.9, 0.6)
return l
static func set_status(lbl: Label, msg: String, is_err: bool = false) -> void:
if lbl:
lbl.text = msg
lbl.modulate = Color(1.0, 0.5, 0.5) if is_err else Color(0.6, 0.9, 0.6)
static func load_json(path: String):
if not FileAccess.file_exists(path):
return null
return JSON.parse_string(FileAccess.get_file_as_string(path))
static func save_json(path: String, data) -> bool:
DirAccess.make_dir_recursive_absolute("res://data")
var f := FileAccess.open(path, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(data, " "))
f.close()
return true
return false
```
- [ ] **Step 2: 校验**
调用 `mcp__godot-mcp-pro__validate_script``path = "res://addons/game_designer/designer_ui.gd"`。Expected: 通过。
- [ ] **Step 3: 提交**
```bash
git add addons/game_designer/designer_ui.gd
git commit -m "feat(designer): 输入框撑满面板 + 共享元素词表常量"
```
---
### Task 2: `spell_tab.gd` — 元素复选框 + 双语标签
**Files:**
- Modify: `addons/game_designer/spell_tab.gd`
(以下 6 处用 Edit 工具精确替换;保留 TAB 缩进。依赖 Task 1 的 `UI.ELEM_LABELS`/`UI.ELEM_TAGS`。)
- [ ] **Step 1: 常量改双语 + 新增 LOGIC_OP_LABELS**
old:
```gdscript
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 整数
```
new:
```gdscript
const TYPE_NAMES := ["动作 ACTION", "修饰 MODIFIER", "触发 TRIGGER", "逻辑 LOGIC"]
const ACTION_KINDS := ["弹道 Projectile", "毒池 Zone", "召唤 Summon"] # 0=默认(不写 action_kind) 1=zone 2=summon
const LOGIC_OPS := ["every_n_shots", "if_hp_below", "if_enemy_nearby", "loop"] # 存储 key,不变
const LOGIC_OP_LABELS := ["每 N 次 every_n_shots", "生命低于 if_hp_below", "附近有敌 if_enemy_nearby", "循环 loop"] # 下拉显示
const DMG_LABELS := ["物理 Physical", "火 Fire", "冰 Ice", "雷 Lightning", "毒 Poison", "奥术 Arcane"] # index = damage_type 整数
const ST_LABELS := ["无 None", "燃烧 Burn", "冻结 Freeze", "中毒 Poison", "潮湿 Wet", "沾油 Oily", "眩晕 Stun", "连击 Combo", "易伤 Vuln"] # index = status 整数
```
- [ ] **Step 2: 成员变量 —— 替换 `_tags`**
old:
```gdscript
var _tags: LineEdit
```
new:
```gdscript
var _elem_checks: Array = []
var _custom_tags: LineEdit
```
- [ ] **Step 3: `_ready()` —— 元素标签行换复选框 + 自定义框**
old:
```gdscript
_tags = _row("元素标签", UI.line("", "逗号分隔,如 tag:fire"))
```
new:
```gdscript
var erow := HBoxContainer.new()
erow.add_child(UI.cell_label("元素标签"))
_elem_checks.clear()
for i in UI.ELEM_LABELS.size():
var cb := CheckBox.new(); cb.text = UI.ELEM_LABELS[i]
_elem_checks.append(cb); erow.add_child(cb)
add_child(erow)
_custom_tags = _row("自定义标签", UI.line("", "非元素 tag,逗号分隔"))
```
- [ ] **Step 4: `_ready()` —— `_lop` 下拉改用双语显示数组**
old:
```gdscript
_lop = UI.opt(LOGIC_OPS, 0)
```
new:
```gdscript
_lop = UI.opt(LOGIC_OP_LABELS, 0)
```
- [ ] **Step 5: `_on_select()` —— 载入元素勾选 + 自定义**
old:
```gdscript
_tags.text = ",".join(d.get("element_tags", []))
```
new:
```gdscript
var etags: Array = d.get("element_tags", [])
for i in _elem_checks.size():
_elem_checks[i].button_pressed = UI.ELEM_TAGS[i] in etags
var custom_list := []
for t in etags:
if not (String(t) in UI.ELEM_TAGS): custom_list.append(String(t))
_custom_tags.text = ",".join(custom_list)
```
- [ ] **Step 6: `_on_apply()` —— 收集勾选 + 自定义**
old:
```gdscript
var tags: Array = []
for t in _tags.text.split(",", false):
var s := t.strip_edges()
if s != "": tags.append(s)
```
new:
```gdscript
var tags: Array = []
for i in _elem_checks.size():
if _elem_checks[i].button_pressed: tags.append(UI.ELEM_TAGS[i])
for t in _custom_tags.text.split(",", false):
var s := t.strip_edges()
if s != "" and not (s in tags): tags.append(s)
```
- [ ] **Step 7: 校验**
调用 `mcp__godot-mcp-pro__validate_script``path = "res://addons/game_designer/spell_tab.gd"`。Expected: 通过。
- [ ] **Step 8: 提交**
```bash
git add addons/game_designer/spell_tab.gd
git commit -m "feat(spell-editor): 元素标签改复选框 + 选项双语显示"
```
---
### Task 3: `resonance_tab.gd` — 双语 + 元素下拉
**Files:**
- Modify: `addons/game_designer/resonance_tab.gd`
- [ ] **Step 1: 常量 —— 新增 MATCH_LABELS**
old:
```gdscript
const MATCH = ["adjacent", "anywhere_in_deck"]
```
new:
```gdscript
const MATCH = ["adjacent", "anywhere_in_deck"] # 存储 key
const MATCH_LABELS = ["相邻 adjacent", "卡组内任意 anywhere_in_deck"]
```
- [ ] **Step 2: 成员变量 —— `_p0`/`_p1` 改 OptionButton**
old:
```gdscript
var _p0: LineEdit
var _p1: LineEdit
```
new:
```gdscript
var _p0: OptionButton
var _p1: OptionButton
```
(注意:这两行在文件里位于 `var` 声明区,缩进为单 TAB;若实际无前导 TAB,请按文件实际缩进匹配。)
- [ ] **Step 3: `_ready()` —— 元素 A/B 与匹配方式换下拉**
old:
```gdscript
_p0 = _row("元素 A", UI.line("", "tag:water"))
_p1 = _row("元素 B", UI.line("", "tag:lightning"))
_match = _row("匹配方式", UI.opt(MATCH, 0))
```
new:
```gdscript
_p0 = _row("元素 A", UI.opt(UI.ELEM_LABELS, 0))
_p1 = _row("元素 B", UI.opt(UI.ELEM_LABELS, 0))
_match = _row("匹配方式", UI.opt(MATCH_LABELS, 0))
```
- [ ] **Step 4: `_on_select()` —— 按 tag 选中元素**
old:
```gdscript
_p0.text = String(p[0]) if p.size() > 0 else ""
_p1.text = String(p[1]) if p.size() > 1 else ""
_match.selected = max(0, MATCH.find(String(d.get("match", "adjacent"))))
```
new:
```gdscript
_p0.selected = max(0, UI.ELEM_TAGS.find(String(p[0])) if p.size() > 0 else 0)
_p1.selected = max(0, UI.ELEM_TAGS.find(String(p[1])) if p.size() > 1 else 0)
_match.selected = max(0, MATCH.find(String(d.get("match", "adjacent"))))
```
- [ ] **Step 5: `_on_apply()` —— 写回 tag**
old:
```gdscript
"pattern": [_p0.text.strip_edges(), _p1.text.strip_edges()],
"match": MATCH[_match.selected],
```
new:
```gdscript
"pattern": [UI.ELEM_TAGS[_p0.selected], UI.ELEM_TAGS[_p1.selected]],
"match": MATCH[_match.selected],
```
- [ ] **Step 6: `_on_new()` —— 默认配方用合法元素**
old:
```gdscript
_data.append({"id": "new_recipe", "pattern": ["tag:a", "tag:b"],
```
new:
```gdscript
_data.append({"id": "new_recipe", "pattern": ["tag:water", "tag:lightning"],
```
- [ ] **Step 7: 校验**
调用 `mcp__godot-mcp-pro__validate_script``path = "res://addons/game_designer/resonance_tab.gd"`。Expected: 通过。
- [ ] **Step 8: 提交**
```bash
git add addons/game_designer/resonance_tab.gd
git commit -m "feat(designer): 共鸣配方元素 A/B 改下拉 + 匹配方式双语"
```
---
### Task 4: wave / core / status 标签双语归一
**Files:**
- Modify: `addons/game_designer/wave_tab.gd`
- Modify: `addons/game_designer/core_tab.gd`
- Modify: `addons/game_designer/status_tab.gd`
- [ ] **Step 1: `wave_tab.gd` 常量双语**
old:
```gdscript
const TYPE_NAMES = ["Basic", "Fast", "Armored", "Elite", "MiniBoss", "Boss"]
const BOSS_NAMES = ["无", "MiniBoss(4)", "Boss(5)"] # 映射到 boss 值 -1/4/5
```
new:
```gdscript
const TYPE_NAMES = ["杂兵 Basic", "快速 Fast", "护甲 Armored", "精英 Elite", "首领 MiniBoss", "终焉 Boss"]
const BOSS_NAMES = ["无 None", "首领 MiniBoss", "终焉 Boss"] # 映射到 boss 值 -1/4/5
```
- [ ] **Step 2: `core_tab.gd` 常量归一「中文 English」**
old:
```gdscript
const TOPO = ["LINEAR 线性", "MATRIX 矩阵", "CIRCUIT 电路"]
const FEAT = ["无", "PERSISTENT_MEMORY 记忆", "DUAL_STREAM 双流", "ALWAYS_CAST_LAST", "SHUFFLE_DECK", "INFINITE_SPELLS"]
```
new:
```gdscript
const TOPO = ["线性 LINEAR", "矩阵 MATRIX", "电路 CIRCUIT"]
const FEAT = ["无 None", "记忆 PERSISTENT_MEMORY", "双流 DUAL_STREAM", "末位施法 ALWAYS_CAST_LAST", "洗牌 SHUFFLE_DECK", "无限法术 INFINITE_SPELLS"]
```
- [ ] **Step 3: `status_tab.gd` 常量归一「中文 English」**
old:
```gdscript
const STACK = ["REFRESH 刷新", "INTENSITY 叠层", "INDEPENDENT 独立"]
```
new:
```gdscript
const STACK = ["刷新 REFRESH", "叠层 INTENSITY", "独立 INDEPENDENT"]
```
- [ ] **Step 4: 校验三个文件**
分别调用 `mcp__godot-mcp-pro__validate_script``res://addons/game_designer/wave_tab.gd``res://addons/game_designer/core_tab.gd``res://addons/game_designer/status_tab.gd`。Expected: 均通过。
- [ ] **Step 5: 提交**
```bash
git add addons/game_designer/wave_tab.gd addons/game_designer/core_tab.gd addons/game_designer/status_tab.gd
git commit -m "feat(designer): 波次/Core/状态 枚举标签归一为中英双语"
```
---
### Task 5: 手册文档更新(元素标签复选框)
**Files:**
- Modify: `docs/handbook/06_spell_editor.md`
- [ ] **Step 1: 更新操作步骤中的元素标签说明**
`docs/handbook/06_spell_editor.md` 第 3 节,找到这一步(新增法术流程里的第 5 步):
```markdown
5.**元素标签**(共鸣用,如 `tag:lightning`;多个用逗号)
```
替换为:
```markdown
5. 勾选 **元素标签**(火/冰/雷/水/毒 复选框,可多选;共鸣用);非元素标签填「自定义标签」框
```
- [ ] **Step 2: 更新界面示意图中的元素标签行**
在同文件第 2 节界面示意图里,找到:
```
ID(唯一)/ 类型[ACTION ▾]/ 显示名 / 描述 / 元素标签(逗号)
```
替换为:
```
ID(唯一)/ 类型[动作 ACTION ▾]/ 显示名 / 描述
元素标签:[✓]火 [ ]冰 [✓]雷 [ ]水 [ ]毒 + 自定义标签框
```
- [ ] **Step 3: 提交**
```bash
git add docs/handbook/06_spell_editor.md
git commit -m "docs(spell-editor): 元素标签改复选框说明"
```
---
### Task 6: 全量验证(回读往返 + 目测)
**Files:**
- 无(MCP 验证,不落盘)
- [ ] **Step 1: 重载并查错**
调用 `mcp__godot-mcp-pro__reload_project`,再 `mcp__godot-mcp-pro__get_editor_errors`max_lines 50)。Expected: 无与本次改动相关的报错。(可能残留早前一次性 `gdscript://` 脚本的 `eq` 解析错,属无关历史噪声。)
- [ ] **Step 2: 法术回读往返(含 element_tags 集合比较)**
调用 `mcp__godot-mcp-pro__execute_editor_script``code`
```gdscript
var UIH = load("res://addons/game_designer/designer_ui.gd")
var tab = load("res://addons/game_designer/spell_tab.gd").new()
EditorInterface.get_base_control().add_child(tab)
var raw = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json"))
var fails = []
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(sid + ":未在列表"); continue
var m = tab._read_meta()
if not (m is Dictionary):
fails.append(sid + ":read_meta null"); continue
var orig = 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.meta.%s" % [sid, k])
for k in m:
if not orig.has(k): fails.append("%s:+meta.%s" % [sid, k])
# element_tags 作为集合比较
var et_orig = raw[sid].get("element_tags", [])
var et_new = []
for i in tab._elem_checks.size():
if tab._elem_checks[i].button_pressed: et_new.append(UIH.ELEM_TAGS[i])
for t in tab._custom_tags.text.split(",", false):
var s = t.strip_edges()
if s != "" and not (s in et_new): et_new.append(s)
for t in et_orig:
if not (String(t) in et_new): fails.append("%s:tag缺 %s" % [sid, str(t)])
for t in et_new:
if not (t in et_orig): fails.append("%s:tag多 %s" % [sid, str(t)])
_mcp_print("SPELL FAILS=%d %s" % [fails.size(), str(fails)])
tab.queue_free()
```
Expected: `SPELL FAILS=0 []`。若非 0,按报错定位并修相应文件后重跑。
- [ ] **Step 3: 共鸣回读往返**
调用 `mcp__godot-mcp-pro__execute_editor_script``code`
```gdscript
var UIH = load("res://addons/game_designer/designer_ui.gd")
var tab = load("res://addons/game_designer/resonance_tab.gd").new()
EditorInterface.get_base_control().add_child(tab)
var raw = JSON.parse_string(FileAccess.get_file_as_string("res://data/resonance.json"))
var fails = []
for idx in raw.size():
tab._on_select(idx)
var orig = raw[idx]
var op = orig.get("pattern", ["", ""])
var np = [UIH.ELEM_TAGS[tab._p0.selected], UIH.ELEM_TAGS[tab._p1.selected]]
if String(op[0]) != np[0] or String(op[1]) != np[1]:
fails.append("%s:pattern %s->%s" % [str(orig.get("id","?")), str(op), str(np)])
var nm = tab.MATCH[tab._match.selected]
if String(orig.get("match","adjacent")) != nm:
fails.append("%s:match %s->%s" % [str(orig.get("id","?")), str(orig.get("match")), nm])
_mcp_print("RESONANCE FAILS=%d %s" % [fails.size(), str(fails)])
tab.queue_free()
```
Expected: `RESONANCE FAILS=0 []`。(前提:`resonance.json` 现有配方 pattern 均为元素 tag;若某条含非元素值会在此暴露。)
- [ ] **Step 4: 目测布局与双语**
调用 `mcp__godot-mcp-pro__get_editor_screenshot`(若底部面板未展开,此步可跳过并改由用户人工确认)。确认:输入框/下拉撑满面板宽、下拉显示中英双语、法术页元素标签为复选框行。记录观察结果。
- [ ] **Step 5: 无新增提交(本任务仅验证)**
若 Step 2/3 触发修复,则各自 `git add <改动文件> && git commit -m "fix(designer): 修正回读往返"`;否则本任务无提交。
---
## Self-Review
**Spec 覆盖:**
- §2 布局撑满 → Task 1line/opt/spin 加 EXPAND_FILL)✅
- §3 共享元素词表 → Task 1ELEM_LABELS/ELEM_TAGS)✅
- §4.1 元素复选框 + 自定义框 → Task 2 Step 2/3/5/6 ✅
- §4.2 spell 双语 + LOGIC_OP_LABELS → Task 2 Step 1/4 ✅
- §5 wave 双语 → Task 4 Step 1 ✅
- §6 resonance 双语 + 元素下拉 → Task 3 ✅
- §7 core/status 归一,enemy/balance 不动 → Task 4 Step 2/3enemy/balance 无任务,符合)✅
- §9 验证 → Task 6 ✅
- 文档(元素标签复选框)→ Task 5 ✅
**占位符扫描:** 无 TBD/TODO;每步含精确 old/new 代码块或命令。✅
**类型/命名一致性:** `UI.ELEM_LABELS`/`UI.ELEM_TAGS`Task 1 定义,Task 2/3/6 引用一致);`_elem_checks`/`_custom_tags`Task 2 定义,Task 6 引用);`LOGIC_OP_LABELS`Task 2 定义并用于 `_lop`);`MATCH_LABELS`Task 3);resonance `_p0/_p1` 由 LineEdit→OptionButton 全程一致。回读脚本经 `tab.UI.ELEM_TAGS`/`tab.MATCH` 访问常量(GDScript 实例可经实例访问脚本常量)。✅
**已知取舍:** 多元素法术 `element_tags` 规整为「火冰雷水毒」固定序(集合语义,Task 6 按集合断言);resonance 非元素 pattern 会被下拉回退(现数据无此情况,Task 6 Step 3 会暴露)。