# 游戏设计器 整数元组列表编辑器（edges / aether_thresholds 去 JSON）— 设计

- 日期：2026-07-22
- 状态：设计已确认（方案），待细化评审
- 范围：编辑器工具层 `addons/game_designer/`，**游戏运行时代码与数据文件格式零改动**

---

## 1. 背景与问题

游戏设计器仍有两处「可变长度小整数元组列表」用手写 JSON 文本框，易配置错、可读性差：

- **Core `edges`（电路边）** —— `core_tab.gd` 的 `电路边(CIRCUIT，JSON)` TextEdit。CIRCUIT 拓扑法杖的槽位有向连线：`[{"from":0,"to":1},{"from":0,"to":2}]`。当前仅 `circuit_fork` 使用。
- **平衡 `aether_thresholds`（碎片阈值）** —— `balance_tab.gd` 的 `碎片奖励阈值（JSON [[波次,碎片],...]）` TextEdit。元进度奖励表：`[[1,0],[5,3],[10,8],[15,14],[20,20]]` = [波次, 累计碎片]。

法术 `其它(JSON)` 是**逃生舱**（收纳 schema 之外的未知键，本质装任意键、无法字段化），**保持不动**，不在本次范围。

### 目标
用一个**共享的行编辑器**替代这两处 JSON 文本：每行 N 个 SpinBox + 🗑 删行，底部 ➕ 加行。

---

## 2. 新组件：`tuple_list_editor.gd`

新建 `addons/game_designer/tuple_list_editor.gd`，`@tool extends VBoxContainer`，可复用行编辑器。

### 2.1 数据模型
- 对外数据 = `Array[Array[number]]`（行的数组，每行一组数值）。调用方负责与自身 JSON 结构（dict / array）互转。

### 2.2 API
```gdscript
## col_specs: Array[Dictionary]，每列一项：
##   {"label": String, "min": float, "max": float, "step": float, "int": bool}
##   label 可为 ""（不显示列标签）；int=true 时 get_values 返回该列为整数
func setup(col_specs: Array, add_label: String = "➕ 加行") -> void
func set_values(rows: Array) -> void      # rows: Array[Array]；清空并重建
func get_values() -> Array                # 返回 Array[Array]，按列 int 规格转 int
```

### 2.3 行为
- `setup`：记录列规格，创建内部 `_rows_box: VBoxContainer` + 底部 ➕ 按钮（点按追加一空行，值取各列 default 或 min）。
- 每行：一个 HBoxContainer，含（可选列标签 Label +）N 个 SpinBox（`size_flags_horizontal = SIZE_EXPAND_FILL`）+ 尾部 🗑 按钮。🗑 处理器：`_rows_box.remove_child(row); row.queue_free()`（**立即移出树**，保证随后的 `get_values` 不再计入）。
- `set_values`：先**立即** `remove_child + free` 所有旧行（不用 queue_free，避免同帧残留），再逐行 `_add_row`。
- `get_values`：遍历 `_rows_box` 现存行，收集每行 SpinBox 值；`int` 列 `int(v)`，否则 `float(v)`。

### 2.4 边界
- 无排序/校验（YAGNI）；行顺序即用户可见顺序。
- 不做去重（edges/aether 语义允许重复行；调用方如需可自行处理，本次不做）。

---

## 3. 集成：`core_tab.gd`（edges）

- 成员：`var _edges: TextEdit` → `var _edges: <TupleListEditor 实例>`（类型标注用 `Control` 或不标注，经 `.new()` 创建）。
- `_ready()`：把原
  ```gdscript
  var el := Label.new(); el.text = "电路边(CIRCUIT，JSON)"; add_child(el)
  _edges = TextEdit.new(); _edges.custom_minimum_size = Vector2(0, 50)
  _edges.placeholder_text = '[{"from":0,"to":1},{"from":0,"to":2}]'
  add_child(_edges)
  ```
  替换为：
  ```gdscript
  var el := Label.new(); el.text = "电路边(CIRCUIT，from → to)"; add_child(el)
  _edges = preload("res://addons/game_designer/tuple_list_editor.gd").new()
  _edges.setup([
      {"label": "from", "min": 0, "max": 63, "step": 1, "int": true},
      {"label": "→ to", "min": 0, "max": 63, "step": 1, "int": true}], "➕ 加边")
  add_child(_edges)
  ```
- `_on_select()`：`_edges.text = JSON.stringify(d.get("edges", []))` →
  ```gdscript
  var erows := []
  for e in d.get("edges", []):
      erows.append([int(e.get("from", 0)), int(e.get("to", 0))])
  _edges.set_values(erows)
  ```
- `_on_apply()`：原
  ```gdscript
  var edges = JSON.parse_string(_edges.text) if _edges.text.strip_edges() != "" else []
  if not (edges is Array):
      edges = []
  ```
  替换为：
  ```gdscript
  var edges := []
  for r in _edges.get_values():
      edges.append({"from": r[0], "to": r[1]})
  ```
  （后续 `_data[cid] = { ... "edges": edges, ... }` 不变。）
- 其余（拓扑/矩阵行列等）不变。max=63 为槽位上限的宽松值（当前 slot_count ≤ 8，给足冗余）。

---

## 4. 集成：`balance_tab.gd`（aether_thresholds）

- 成员：`var _aether: TextEdit` → `var _aether: <TupleListEditor 实例>`。
- `_ready()`：把原
  ```gdscript
  var al := Label.new(); al.text = "碎片奖励阈值 aether_thresholds（JSON [[波次,碎片],...]）"; add_child(al)
  _aether = TextEdit.new(); _aether.custom_minimum_size = Vector2(0, 50)
  _aether.text = JSON.stringify(_data.get("aether_thresholds", [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]))
  add_child(_aether)
  ```
  替换为：
  ```gdscript
  var al := Label.new(); al.text = "碎片奖励阈值 aether_thresholds（波次 → 碎片）"; add_child(al)
  _aether = preload("res://addons/game_designer/tuple_list_editor.gd").new()
  _aether.setup([
      {"label": "波次", "min": 1, "max": 9999, "step": 1, "int": true},
      {"label": "碎片", "min": 0, "max": 9999, "step": 1, "int": true}], "➕ 加行")
  add_child(_aether)
  _aether.set_values(_data.get("aether_thresholds", [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]))
  ```
- `_save()`：原
  ```gdscript
  var thresholds = JSON.parse_string(_aether.text) if _aether.text.strip_edges() != "" else []
  if not (thresholds is Array):
      thresholds = []
  ```
  替换为：
  ```gdscript
  var thresholds := _aether.get_values()
  ```
  （`out["aether_thresholds"] = thresholds` 不变。）

---

## 5. 组件边界与改动清单

| 文件 | 改动 |
|---|---|
| `tuple_list_editor.gd`（新建） | 可复用整数元组行编辑器（setup/set_values/get_values） |
| `core_tab.gd` | `edges` 文本框 → 行编辑器；dict↔array 边界转换 |
| `balance_tab.gd` | `aether_thresholds` 文本框 → 行编辑器 |

不动：spell `其它(JSON)`、运行时脚本、数据文件格式与内容、其它 tab。

---

## 6. 验证

1. `validate_script` 三文件全绿；`reload_project` + `get_editor_errors` 无报错（GDScript 类缓存问题可能需重启编辑器，同上一增量）。
2. 回读往返（`execute_editor_script`）：
   - **core**：实例化 core_tab，对 `cores.json` 每个 core `_on_select` → `_on_apply` 组装的 `edges` 与原一致（`circuit_fork` 两条边；无 edges 的 core 结果为空数组、原本也无该键——比较时空数组 vs 缺键视为等价）。
   - **balance**：实例化 balance_tab（`_ready` 后）→ `_aether.get_values()` 与 `balance.json` 的 `aether_thresholds` 逐元素一致。
3. 目测：Core 页电路边为 from/to 行 + 加/删；平衡页碎片阈值为 波次/碎片 行 + 加/删（重启编辑器后由用户确认）。

## 7. 范围外（YAGNI）

- 不动 spell `其它(JSON)`（逃生舱）。
- 行编辑器不做排序/去重/跨行校验。
- 不改数据文件内容（现有 edges/aether 原样回读）。

## 8. 验收标准

1. Core 页「电路边」为 from→to 的 SpinBox 行，可加/删；`circuit_fork` 正确显示两条边 (0→1, 0→2)，应用保存后 `cores.json` 的 `edges` 不变。
2. 平衡页「碎片阈值」为 波次/碎片 的 SpinBox 行，可加/删；显示 5 行，保存后 `balance.json` 的 `aether_thresholds` 不变。
3. `validate_script` 无报错；core 与 balance 回读往返一致。
4. spell `其它(JSON)` 仍在、未被改动。
