Files
mtgodot-poc/project/ui/ui_build.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

508 lines
19 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.
# UiBuild (P1) —— uiscript 嵌套 Dictionary → Godot Control 树。
#
# var spec := UiScript.new().parse_file(path)
# var r := UiBuild.build(spec, assets_root)
# add_child(r.root)
# r.nodes["accept"].pressed.connect(...) # 按 uiscript 里的 "name" 取节点
#
# 覆盖 uiscript 最常用的 ~18 种 type;其它降级为 Control / Button / TextureRect。
# board/thinboard 的九宫格用 ui_kit(真实 ymir 贴图,缺失则 StyleBoxFlat 兜底)。
extends RefCounted
const UiKit = preload("res://ui_kit.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
# spec: 顶层 window dict。返回 { root: Control, nodes: {name: Control} }
static func build(spec: Dictionary, assets_root: String) -> Dictionary:
var ctx := {"assets": assets_root, "nodes": {}}
var root := _make(spec, ctx, Vector2i.ZERO)
if root == null:
root = Control.new()
return {"root": root, "nodes": ctx["nodes"]}
# --- recursion ---------------------------------------------------------
static func _i(spec: Dictionary, key: String, def := 0) -> int:
var v = spec.get(key, def)
return int(v) if (v is int or v is float or v is bool or v is String) else def
static func _str(v) -> String:
if v == null:
return ""
return v if v is String else str(v)
static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> Control:
var type := _str(spec.get("type", "window"))
var w := _i(spec, "width", 0)
var h := _i(spec, "height", 0)
if type == "titlebar" and h == 0:
h = 23
elif type == "horizontalbar" and h == 0:
h = 17
elif (type == "grid_table" or type == "slot") and (w == 0 or h == 0):
if w == 0 and spec.has("x_count"):
var xc := maxi(1, _i(spec, "x_count", 1))
var xs := _i(spec, "x_step", 32)
var xb := _i(spec, "x_blank", 0)
w = xc * (xs + xb)
if h == 0 and spec.has("y_count"):
var yc := maxi(1, _i(spec, "y_count", 1))
var ys := _i(spec, "y_step", 32)
var yb := _i(spec, "y_blank", 0)
h = yc * (ys + yb)
var node := _make_by_type(type, spec, ctx, Vector2i(w, h))
if node == null:
node = Control.new()
node.name = _safe_name(_str(spec.get("name", type)))
var halign := _str(spec.get("text_horizontal_align", ""))
var valign := _str(spec.get("text_vertical_align", ""))
var allalign := _str(spec.get("all_align", ""))
if type == "text":
if allalign == "center":
if w == 0: w = parent_size.x
if h == 0: h = parent_size.y
else:
if halign == "center" and w == 0:
w = parent_size.x if parent_size.x > 0 else 100
if (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == 0:
h = parent_size.y if parent_size.y > 0 else 17
if (w == 0 or h == 0) and node is TextureRect and (node as TextureRect).texture != null:
var tex: Texture2D = (node as TextureRect).texture
if w == 0: w = tex.get_width()
if h == 0: h = tex.get_height()
elif (w == 0 or h == 0) and node is Button:
var btn := node as Button
var nrm_sb = btn.get_theme_stylebox("normal")
if nrm_sb is StyleBoxTexture and (nrm_sb as StyleBoxTexture).texture != null:
var tex: Texture2D = (nrm_sb as StyleBoxTexture).texture
if w == 0: w = tex.get_width()
if h == 0: h = tex.get_height()
if w > 0:
node.custom_minimum_size = Vector2(w, h)
node.size = Vector2(w, h)
# 位置:x/y + horizontal_align / vertical_align(相对父)
var x := _i(spec, "x", 0)
var y := _i(spec, "y", 0)
if type == "text" and halign == "center" and _i(spec, "width", 0) == 0:
x = 0
match _str(spec.get("horizontal_align", "left")):
"center": x = (parent_size.x - w) / 2 + x
"right": x = parent_size.x - w - x
match _str(spec.get("vertical_align", "top")):
"center":
if type == "text" and (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == parent_size.y:
pass
else:
y = (parent_size.y - h) / 2 + y
"bottom": y = parent_size.y - h - y
node.position = Vector2(x, y)
var nm := _str(spec.get("name", ""))
if nm != "":
ctx["nodes"][nm] = node
var self_size := Vector2i(w, h) if (w > 0 and h > 0) else parent_size
for child in spec.get("children", []):
if child is Dictionary:
var c := _make(child, ctx, self_size)
if c:
node.add_child(c)
return node
static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: Vector2i) -> Control:
var assets: String = ctx["assets"]
match type:
"window", "box":
var c := Control.new()
c.mouse_filter = Control.MOUSE_FILTER_PASS
return c
"board", "thinboard", "board_with_titlebar":
var p := _board(assets, "thinboard" if type == "thinboard" else "board", sz)
if type == "board_with_titlebar" and spec.has("title"):
var tl := Label.new()
tl.name = "TitleText"
tl.text = _str(spec.get("title", ""))
tl.position = Vector2(12, 6)
p.add_child(tl)
p.set_meta("titlebar_h", 24)
return p
"titlebar":
return _titlebar(assets, sz.x, spec)
"text":
var lbl := Label.new()
lbl.text = _str(spec.get("text", ""))
if spec.has("text_color"):
lbl.add_theme_color_override("font_color", _argb(int(spec["text_color"])))
elif spec.has("color"):
lbl.add_theme_color_override("font_color", _argb(int(spec["color"])))
elif spec.has("r") and spec.has("g") and spec.has("b"):
var r := float(spec["r"])
var g := float(spec["g"])
var b := float(spec["b"])
var a := float(spec.get("a", 1.0))
lbl.add_theme_color_override("font_color", Color(r, g, b, a))
else:
lbl.add_theme_color_override("font_color", Color(0.8549, 0.8549, 0.8549))
var font_size := 12
var fs_spec := _str(spec.get("fontsize", "")).to_upper()
var fn_spec := _str(spec.get("fontname", "")).to_upper()
if fs_spec == "LARGE" or "LARGE" in fn_spec:
font_size = 14
elif fs_spec == "SMALL" or "SMALL" in fn_spec:
font_size = 9
lbl.add_theme_font_size_override("font_size", font_size)
var halign := _str(spec.get("text_horizontal_align", ""))
var valign := _str(spec.get("text_vertical_align", ""))
if _str(spec.get("all_align", "")) == "center":
halign = "center"
valign = "center"
match halign:
"center": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_: lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
match valign:
"center": lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
"bottom": lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_: lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
return lbl
"button", "toggle_button", "radio_button":
var b := Button.new()
b.text = _str(spec.get("text", ""))
b.toggle_mode = (type == "toggle_button" or type == "radio_button")
if spec.has("text_color"):
var col := _argb(int(spec["text_color"]))
b.add_theme_color_override("font_color", col)
b.add_theme_color_override("font_pressed_color", col)
b.add_theme_color_override("font_hover_color", col)
b.add_theme_color_override("font_focus_color", col)
elif spec.has("color"):
var col := _argb(int(spec["color"]))
b.add_theme_color_override("font_color", col)
b.add_theme_color_override("font_pressed_color", col)
b.add_theme_color_override("font_hover_color", col)
b.add_theme_color_override("font_focus_color", col)
b.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
b.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
b.alignment = HORIZONTAL_ALIGNMENT_CENTER
b.clip_text = true
var btn_fsize := 12
var fs_spec := _str(spec.get("fontsize", "")).to_upper()
var fn_spec := _str(spec.get("fontname", "")).to_upper()
if fs_spec == "LARGE" or "LARGE" in fn_spec:
btn_fsize = 14
elif fs_spec == "SMALL" or "SMALL" in fn_spec:
btn_fsize = 9
b.add_theme_font_size_override("font_size", btn_fsize)
var nrm := UiAssets.load_tex(assets, _str(spec.get("default_image", "")))
var ovr := UiAssets.load_tex(assets, _str(spec.get("over_image", "")))
var dwn := UiAssets.load_tex(assets, _str(spec.get("down_image", "")))
var empty := StyleBoxEmpty.new()
empty.content_margin_left = 0
empty.content_margin_right = 0
empty.content_margin_top = 0
empty.content_margin_bottom = 0
if nrm:
b.add_theme_stylebox_override("normal", _sb_tex(nrm))
b.add_theme_stylebox_override("hover", _sb_tex(ovr if ovr else nrm))
b.add_theme_stylebox_override("pressed", _sb_tex(dwn if dwn else nrm))
b.add_theme_stylebox_override("disabled", _sb_tex(nrm))
b.add_theme_stylebox_override("focus", empty)
var tex_w := nrm.get_width()
var tex_h := nrm.get_height()
b.custom_minimum_size = Vector2(tex_w, tex_h)
b.size = Vector2(tex_w, tex_h)
# 40250 对齐:进入节点树时重设尺寸,防止被 Godot 默认最小高度撑大到 31px
b.tree_entered.connect(func():
b.size = b.custom_minimum_size
)
if btn_fsize + 6 > tex_h:
btn_fsize = maxi(8, tex_h - 6)
b.add_theme_font_size_override("font_size", btn_fsize)
else:
b.add_theme_stylebox_override("normal", empty)
b.add_theme_stylebox_override("hover", empty)
b.add_theme_stylebox_override("pressed", empty)
b.add_theme_stylebox_override("focus", empty)
b.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
return b
"image", "expanded_image", "ani_image", "mark":
var ir := TextureRect.new()
ir.texture = UiAssets.load_tex(assets, _str(spec.get("image", "")))
ir.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
ir.stretch_mode = TextureRect.STRETCH_SCALE if type == "expanded_image" \
else TextureRect.STRETCH_KEEP
ir.mouse_filter = Control.MOUSE_FILTER_IGNORE
return ir
"editline":
var e := LineEdit.new()
e.text = _str(spec.get("text", ""))
e.secret = bool(spec.get("secret", false))
if spec.has("input_limit"):
e.max_length = int(spec["input_limit"])
return e
"line":
var ln := ColorRect.new()
ln.color = Color(1, 1, 1, 0.15)
if _i(spec, "height", 0) == 0:
ln.custom_minimum_size = Vector2(_i(spec, "width", 100), 1)
return ln
"listbox", "candidate_list":
return ItemList.new()
"gauge", "bar":
var pb := ProgressBar.new()
pb.show_percentage = false
pb.value = 100.0
return pb
"horizontalbar":
return _horizontalbar(assets, _i(spec, "width", 100))
"grid_table", "slotbar", "slot":
# Metin2 物品格容器:有 start_index/x_count 或显式 "slot" 元组 -> 建子格;
# 否则 grid_table 退化成普通 GridContainer。
var cells := _slot_cells(spec)
if cells.is_empty() and type == "grid_table":
var g := GridContainer.new()
g.columns = maxi(1, _i(spec, "x_count", _i(spec, "column", 1)))
return g
var s := Control.new()
s.set_meta("is_slot_container", true)
var base_img := _str(spec.get("image", ""))
var base_tex: Texture2D = UiAssets.load_tex(assets, base_img) if base_img != "" else null
for cd in cells:
var cell := Panel.new()
cell.name = "slot_%d" % int(cd["index"])
cell.position = Vector2(cd["x"], cd["y"])
cell.custom_minimum_size = Vector2(cd["w"], cd["h"])
cell.size = Vector2(cd["w"], cd["h"])
cell.set_meta("slot_index", int(cd["index"]))
cell.mouse_filter = Control.MOUSE_FILTER_STOP
if base_tex != null:
cell.add_theme_stylebox_override("panel", _sb_tex(base_tex))
s.add_child(cell)
return s
"sliderbar":
var slider := HSlider.new()
# 40250 sliderbar.sub (175×12), sliderbar_cursor.sub (53×12)
slider.custom_minimum_size = Vector2(175, 12)
slider.size = Vector2(175, 12)
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.01
var track_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar.sub")
var grab_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar_cursor.sub")
if track_tex:
var sb_track := StyleBoxTexture.new()
sb_track.texture = track_tex
sb_track.content_margin_left = 0
sb_track.content_margin_right = 0
sb_track.content_margin_top = 0
sb_track.content_margin_bottom = 0
slider.add_theme_stylebox_override("slider", sb_track)
if grab_tex:
slider.add_theme_icon_override("grabber", grab_tex)
slider.add_theme_icon_override("grabber_highlight", grab_tex)
slider.add_theme_icon_override("grabber_disabled", grab_tex)
return slider
"scrollbar", "thin_scrollbar":
return VScrollBar.new()
_:
return Control.new()
# --- helpers ---------------------------------------------------------
static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
var p := Panel.new()
if sz.x > 0 and sz.y > 0:
p.custom_minimum_size = Vector2(sz)
p.size = Vector2(sz)
var np: NinePatchRect = null
if assets != "":
var m := 16 if prefix == "thinboard" else 32
var base_sz := 32 if prefix == "thinboard" else 128
np = UiKit.board(assets, prefix, m, base_sz)
if np and np.texture:
np.mouse_filter = Control.MOUSE_FILTER_IGNORE
p.add_child(np)
np.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
p.get_child(0).show_behind_parent = true
p.add_theme_stylebox_override("panel", StyleBoxEmpty.new())
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.10, 0.11, 0.14, 0.92)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
sb.set_corner_radius_all(3)
p.add_theme_stylebox_override("panel", sb)
return p
static func _titlebar(assets: String, width: int, spec: Dictionary) -> Control:
var t := Control.new()
var w := maxi(64, width)
t.custom_minimum_size = Vector2(w, 23)
t.size = Vector2(w, 23)
t.set_meta("is_titlebar", true)
# Background textures (left, center, right)
var left_tex := UiKit.ui_tex(assets, "titlebar_left")
var center_tex := UiKit.ui_tex(assets, "titlebar_center")
var right_tex := UiKit.ui_tex(assets, "titlebar_right")
if left_tex and center_tex and right_tex:
var img_left := TextureRect.new()
img_left.name = "TitleBarLeft"
img_left.texture = left_tex
img_left.position = Vector2.ZERO
img_left.size = Vector2(32, 23)
img_left.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(img_left)
var img_center := TextureRect.new()
img_center.name = "TitleBarCenter"
img_center.texture = center_tex
img_center.position = Vector2(32, 0)
img_center.size = Vector2(maxi(0, w - 64), 23)
img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_center.stretch_mode = TextureRect.STRETCH_TILE
img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(img_center)
var img_right := TextureRect.new()
img_right.name = "TitleBarRight"
img_right.texture = right_tex
img_right.position = Vector2(w - 32, 0)
img_right.size = Vector2(32, 23)
img_right.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(img_right)
else:
var p := Panel.new()
p.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.2, 0.2, 0.25, 0.9)
p.add_theme_stylebox_override("panel", sb)
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(p)
# Close Button
var btn_close := Button.new()
btn_close.name = "CloseButton"
var nrm := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_01.sub")
var ovr := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_02.sub")
var dwn := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_03.sub")
if nrm:
btn_close.add_theme_stylebox_override("normal", _sb_tex(nrm))
if ovr: btn_close.add_theme_stylebox_override("hover", _sb_tex(ovr))
if dwn: btn_close.add_theme_stylebox_override("pressed", _sb_tex(dwn))
var bw := nrm.get_width()
var bh := nrm.get_height()
btn_close.size = Vector2(bw, bh)
btn_close.position = Vector2(w - bw - 3, 3)
else:
btn_close.text = "×"
btn_close.size = Vector2(16, 16)
btn_close.position = Vector2(w - 19, 3)
btn_close.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
t.add_child(btn_close)
t.set_meta("close_button", btn_close)
return t
static func _horizontalbar(assets: String, width: int) -> Control:
var w := maxi(96, width)
var bar := Control.new()
bar.custom_minimum_size = Vector2(w, 17)
bar.size = Vector2(w, 17)
bar.mouse_filter = Control.MOUSE_FILTER_PASS
var left_tex := UiKit.ui_tex(assets, "horizontalbar_left")
var center_tex := UiKit.ui_tex(assets, "horizontalbar_center")
var right_tex := UiKit.ui_tex(assets, "horizontalbar_right")
if left_tex and center_tex and right_tex:
var img_left := TextureRect.new()
img_left.name = "HorizontalBarLeft"
img_left.texture = left_tex
img_left.position = Vector2.ZERO
img_left.size = Vector2(32, 17)
img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(img_left)
var img_center := TextureRect.new()
img_center.name = "HorizontalBarCenter"
img_center.texture = center_tex
img_center.position = Vector2(32, 0)
img_center.size = Vector2(maxi(0, w - 64), 17)
img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_center.stretch_mode = TextureRect.STRETCH_TILE
img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(img_center)
var img_right := TextureRect.new()
img_right.name = "HorizontalBarRight"
img_right.texture = right_tex
img_right.position = Vector2(w - 32, 0)
img_right.size = Vector2(32, 17)
img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(img_right)
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.2, 0.22, 0.25, 0.85)
var p := Panel.new()
p.set_anchors_preset(Control.PRESET_FULL_RECT)
p.add_theme_stylebox_override("panel", sb)
bar.add_child(p)
return bar
# slot 容器的子格:显式 "slot" 元组,或 start_index/x_count/y_count/x_step/y_step 网格。
static func _slot_cells(spec: Dictionary) -> Array:
var cells := []
if spec.get("slot", null) is Array:
for sd in spec["slot"]:
if sd is Dictionary:
cells.append({"index": _i(sd, "index"), "x": _i(sd, "x"), "y": _i(sd, "y"),
"w": _i(sd, "width", 32), "h": _i(sd, "height", 32)})
elif spec.has("x_count"):
var si := _i(spec, "start_index", 0)
var xc := maxi(1, _i(spec, "x_count", 1))
var yc := maxi(1, _i(spec, "y_count", 1))
var xs := _i(spec, "x_step", 32)
var ys := _i(spec, "y_step", 32)
var xb := _i(spec, "x_blank", 0)
var yb := _i(spec, "y_blank", 0)
for row in yc:
for col in xc:
cells.append({"index": si + row * xc + col, "x": col * (xs + xb), "y": row * (ys + yb),
"w": xs, "h": ys})
return cells
static func _sb_tex(t: Texture2D) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = t
sb.content_margin_left = 0
sb.content_margin_top = 0
sb.content_margin_right = 0
sb.content_margin_bottom = 0
return sb
static func _argb(v: int) -> Color:
# 0xAARRGGBB
var a := (v >> 24) & 0xff
var r := (v >> 16) & 0xff
var g := (v >> 8) & 0xff
var b := v & 0xff
if a == 0:
a = 255
return Color8(r, g, b, a)
static func _safe_name(s: String) -> String:
var out := s
for bad: String in [".", ":", "@", "/", "%", " "]:
out = out.replace(bad, "_")
return out if out != "" else "node"