Files
spellforge/addons/game_designer/spell_tab.gd
T
joywayerandClaude Opus 5 55f96e1a1c feat(homing): 设计器 MODIFIER 表单加 homing 字段,并补上期遗漏的 bounce 字段
bounce 自上期起一直掉在「其它(JSON)」逃生舱,违反 CLAUDE.md 字段化规范,
与 homing 同处 schema,一并补上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:30:10 +08:00

350 lines
14 KiB
GDScript
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)— 字段化表单,替代原始 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 := ["弹道 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 整数
var _data: Dictionary = {}
var _cur: String = ""
var _list: ItemList
var _id: LineEdit
var _type: OptionButton
var _name: LineEdit
var _desc: LineEdit
var _elem_checks: Array = []
var _custom_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 键或文本"))
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,逗号分隔"))
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_OP_LABELS, 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": "bounce", "l": "弹跳 bounce", "w": "i", "d": 0, "opt": true},
{"k": "homing", "l": "归航 homing", "w": "f", "d": 0.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", ""))
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)
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 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)
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