2026-05-29 19:54:56 +08:00

417 lines
18 KiB
GDScript
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.

class_name UiBuilder
extends RefCounted
# UI 构建工具。
#
# 这个类只负责“怎么画一个通用控件”,不持有任何业务状态。
# 目的:
# - 把重复的 TextureRect/NinePatch/Label/Button 组合从各面板里抽掉。
# - 保持所有弹窗、Tab、物品格、按钮动效的一致性。
# - 避免每个面板都手写 mouse_filter、字体、outline、press scale 这些细节。
const NumberFormatterScript := preload("res://scripts/core/number_formatter.gd")
const I18nScript := preload("res://scripts/core/i18n.gd")
const UiMotionScript := preload("res://scripts/ui/common/ui_motion.gd")
const PANEL_SHELL_PATH := "res://assets/replacement/panels/panel_shell.png"
const PANEL_CONTENT_PATH := "res://assets/replacement/panels/panel_content.png"
const PANEL_SHELL_MARGINS := Vector4(96.0, 184.0, 84.0, 124.0)
const PANEL_CONTENT_MARGINS := Vector4(56.0, 64.0, 56.0, 58.0)
const NON_TITLE_GLYPH_SPACING := -1
const NON_TITLE_SPACE_SPACING := -2
# _owner 用于创建 Tween 和播放按钮按压动效;真正的 UI 节点仍由调用方持有。
var _owner: Control
var _panel_screen_size := Vector2(640.0, 960.0)
var _font: Font
var _title_font: Font
var _button_font: Font
var _body_font: Font
var _texture_provider
# 关闭弹窗、物品图标解析都由 GameScene 注入UiBuilder 不直接知道业务对象。
var _close_modal_callback: Callable
var _get_item_icon_texture: Callable
var _get_item_icon_id: Callable
func setup(options: Dictionary) -> void:
_owner = options.get("owner") as Control
_panel_screen_size = options.get("panel_screen_size", _panel_screen_size)
var font_options = options.get("fonts", {})
if font_options is Dictionary:
_title_font = font_options.get("title") as Font
_button_font = font_options.get("button") as Font
_body_font = font_options.get("body") as Font
var fallback_font := options.get("font") as Font
_body_font = _body_font if _body_font != null else fallback_font
_button_font = _button_font if _button_font != null else _body_font
_title_font = _title_font if _title_font != null else _button_font
_title_font = _font_variation(_title_font, 0, 0)
_button_font = _font_variation(_button_font, NON_TITLE_GLYPH_SPACING, NON_TITLE_SPACE_SPACING)
_body_font = _font_variation(_body_font, NON_TITLE_GLYPH_SPACING, NON_TITLE_SPACE_SPACING)
_font = _body_font
_texture_provider = options.get("texture_provider")
_close_modal_callback = options.get("close_modal_callback", Callable())
_get_item_icon_texture = options.get("get_item_icon_texture", Callable())
_get_item_icon_id = options.get("get_item_icon_id", Callable())
func create_common_panel_shell(panel_name: String, panel_size: Vector2, icon_key: String, icon_atlas: String, title_key: String, title_atlas: String) -> Control:
# 旧 Egret 面板大量用图集里的 icon/title。这里封装区域裁剪再交给通用 shell。
return create_panel_shell(
panel_name,
panel_size,
_texture_provider.atlas_texture(icon_atlas, _texture_provider.region_for(icon_atlas, icon_key)),
_texture_provider.atlas_texture(title_atlas, _texture_provider.region_for(title_atlas, title_key))
)
func create_panel_shell(panel_name: String, panel_size: Vector2, icon: Texture2D, title: Texture2D) -> Control:
# 弹窗根节点固定为 640x960 设计稿坐标,内部面板居中。
# 子面板只需要传自己的视觉尺寸和标题素材,避免重复处理遮罩、背景、关闭按钮。
var root := create_modal_root(panel_name)
var bg_position := Vector2((_panel_screen_size.x - panel_size.x) * 0.5, (_panel_screen_size.y - panel_size.y) * 0.5)
# Egret scale9Grid 对应 Godot NinePatchRect必须保留边角不被拉伸。
add_panel_shell_background(root, "PanelBg", bg_position, panel_size)
# 标题和图标沿用旧版绝对位置,不按 panel_size 居中,否则和原 UI 会有肉眼偏差。
var title_pos := Vector2(331.0 - title.get_width() * 0.5, bg_position.y - 18.0)
add_texture(root, "PanelTitle", title, title_pos, title.get_size())
var icon_pos := Vector2(142.5 - icon.get_width() * 0.5, bg_position.y + 66.0 - icon.get_height())
add_texture(root, "PanelIcon", icon, icon_pos, icon.get_size())
add_texture_button(root, "PanelClose", _texture_provider.common("c_btn1"), Vector2(220.0, bg_position.y + panel_size.y - 63.0), Vector2(200.0, 70.0), "离开", _close_modal_callback, 36)
return root
func create_modal_root(panel_name: String) -> Control:
var root := Control.new()
root.name = panel_name
root.size = _panel_screen_size
root.mouse_filter = Control.MOUSE_FILTER_STOP
var mask := Button.new()
# 使用 Button 作为遮罩可以吞掉底层点击;样式置空后视觉上不可见。
mask.name = "ModalMask"
mask.set_anchors_preset(Control.PRESET_FULL_RECT)
mask.text = ""
mask.focus_mode = Control.FOCUS_NONE
UiMotionScript.apply_empty_button_style(mask)
root.add_child(mask)
return root
func create_legacy_panel_shell(panel_name: String, bg_position: Vector2, bg_size: Vector2, icon: Texture2D, title: Texture2D, icon_position: Vector2, title_position: Vector2, close_position: Vector2, close_size := Vector2(200.0, 70.0), close_font_size := 36) -> Control:
var root := create_modal_root(panel_name)
add_scale9_patch(root, "PanelBg", _texture_provider.common("c_panel"), bg_position, bg_size, Rect2(122.0, 179.0, 5.0, 4.0))
if title != null:
add_texture(root, "PanelTitle", title, title_position, title.get_size())
if icon != null:
add_texture(root, "PanelIcon", icon, icon_position, icon.get_size())
add_texture_button(root, "PanelClose", _texture_provider.common("c_btn1"), close_position, close_size, "离开", _close_modal_callback, close_font_size)
return root
func add_panel_shell_background(parent: Control, name: String, position: Vector2, size: Vector2) -> NinePatchRect:
return add_nine_patch(parent, name, _texture_provider.standalone(PANEL_SHELL_PATH, _texture_provider.common("c_panel")), position, size, PANEL_SHELL_MARGINS)
func add_content_panel_background(parent: Control, name: String, position: Vector2, size: Vector2) -> NinePatchRect:
return add_nine_patch(parent, name, _texture_provider.standalone(PANEL_CONTENT_PATH, _texture_provider.common("inner_page")), position, size, PANEL_CONTENT_MARGINS)
func create_tab_bar(parent: Control, name: String, labels: Array, position: Vector2, selected_index: int, callback: Callable, base_height := 40.0, max_width := 460.0) -> Control:
# TabBar 是动态宽度:中文标签、英文替换和选中态高度都可能不同。
# 子 Tab 通过 meta 保存 index/textupdate_tab_bar 只根据 meta 重排,不依赖外部数组。
var group := Control.new()
group.name = name
group.position = position
group.size = Vector2(max_width, base_height + 7.0)
group.set_meta("base_height", base_height)
parent.add_child(group)
for index in range(labels.size()):
var source_text := str(labels[index])
var text := I18nScript.t(source_text)
var item := Control.new()
item.name = "Tab%s" % index
item.set_meta("tab_index", index)
item.set_meta("tab_source_text", source_text)
item.mouse_filter = Control.MOUSE_FILTER_STOP
group.add_child(item)
var bg := NinePatchRect.new()
# Tab 背景同样使用九宫格,长文本时不会拉坏圆角和边框。
bg.name = "Bg"
bg.patch_margin_left = 40
bg.patch_margin_top = 20
bg.patch_margin_right = 2
bg.patch_margin_bottom = 2
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
item.add_child(bg)
var label := Label.new()
label.name = "Label"
label.text = text
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
item.add_child(label)
var hit := Button.new()
# hit 覆盖整个 tab视觉节点 mouse_filter_ignore保证点击命中稳定。
hit.name = "Hit"
hit.text = ""
hit.focus_mode = Control.FOCUS_NONE
UiMotionScript.apply_empty_button_style(hit)
hit.pressed.connect(callback.bind(index))
item.add_child(hit)
update_tab_bar(group, selected_index)
return group
func configure_vertical_scroll(scroll: ScrollContainer) -> void:
# 面板列表统一关闭横向滚动,避免手机端误拖出横向偏移。
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
func update_tab_bar(group: Control, selected_index: int) -> void:
# 每次切 tab 都重新计算宽高,保证选中态抬高但不挤压后续控件。
if group == null:
return
var base_height := float(group.get_meta("base_height", 40.0))
var gap := 5.0
var tabs: Array[Dictionary] = []
var desired_total := 0.0
var min_total := 0.0
for child in group.get_children():
if not child is Control or not child.has_meta("tab_index"):
continue
var item := child as Control
var index := int(item.get_meta("tab_index", 0))
var text := I18nScript.t(str(item.get_meta("tab_source_text", "")))
var selected := index == selected_index
var min_width := 105.0 if selected else 88.0
var desired_width := maxf(min_width, _measure_text_width(text, 26, _button_font) + 44.0)
tabs.append({
"item": item,
"index": index,
"text": text,
"selected": selected,
"min_width": min_width,
"width": desired_width,
})
desired_total += desired_width
min_total += min_width
if tabs.is_empty():
return
var gaps_total := gap * maxf(float(tabs.size() - 1), 0.0)
var available_width := maxf(group.size.x - gaps_total, 0.0)
if desired_total > available_width:
var shrink_capacity := maxf(desired_total - min_total, 0.0)
if available_width < min_total and available_width > 0.0:
var even_width := available_width / float(tabs.size())
for tab in tabs:
tab["width"] = even_width
elif shrink_capacity > 0.0:
var target_total := available_width
var shrink_needed := desired_total - target_total
for tab in tabs:
var width := float(tab["width"])
var min_width := float(tab["min_width"])
var capacity := maxf(width - min_width, 0.0)
tab["width"] = width - shrink_needed * (capacity / shrink_capacity)
elif available_width > 0.0:
var even_width := available_width / float(tabs.size())
for tab in tabs:
tab["width"] = even_width
var current_x := 0.0
for tab in tabs:
var item := tab["item"] as Control
var text := str(tab["text"])
var selected := bool(tab["selected"])
var width := float(tab["width"])
var height := base_height + (7.0 if selected else 0.0)
item.position = Vector2(current_x, -4.0 if selected else -2.0)
item.size = Vector2(width, height)
var bg := item.get_node_or_null("Bg") as NinePatchRect
if bg != null:
bg.texture = _texture_provider.common("tab_selected" if selected else "tab_unselecte")
bg.size = item.size
var label := item.get_node_or_null("Label") as Label
if label != null:
label.text = text
label.size = item.size
style_button_label(label, 26, Color.WHITE if selected else Color.html("#764d24"), 3 if selected else 0, Color.html("#6b4c26"))
var hit := item.get_node_or_null("Hit") as Button
if hit != null:
hit.size = item.size
current_x += width + gap
func add_texture(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, stretch_mode := TextureRect.STRETCH_SCALE) -> TextureRect:
# 所有 TextureRect 默认不参与鼠标命中,交互统一交给显式 Button hit 区。
var node := TextureRect.new()
node.name = name
node.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
node.stretch_mode = stretch_mode
node.texture = texture
node.position = position
node.size = size
node.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(node)
return node
func add_nine_patch(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, margins: Vector4) -> NinePatchRect:
# margins 使用 Vector4(left, top, right, bottom),直接映射 Egret scale9Grid 的边距语义。
var node := NinePatchRect.new()
node.name = name
node.texture = texture
node.position = position
node.size = size
node.patch_margin_left = int(margins.x)
node.patch_margin_top = int(margins.y)
node.patch_margin_right = int(margins.z)
node.patch_margin_bottom = int(margins.w)
node.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(node)
return node
func add_scale9_patch(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, scale9_grid: Rect2) -> NinePatchRect:
if texture == null:
return add_nine_patch(parent, name, texture, position, size, Vector4.ZERO)
var right := maxf(float(texture.get_width()) - scale9_grid.position.x - scale9_grid.size.x, 0.0)
var bottom := maxf(float(texture.get_height()) - scale9_grid.position.y - scale9_grid.size.y, 0.0)
return add_nine_patch(parent, name, texture, position, size, Vector4(scale9_grid.position.x, scale9_grid.position.y, right, bottom))
func add_panel_label(parent: Control, name: String, text: String, position: Vector2, size: Vector2, font_size: int, color: Color, outline: int, outline_color: Color, align := HORIZONTAL_ALIGNMENT_CENTER) -> Label:
# 面板内文本默认居中垂直对齐并裁剪,防止后端长字符串覆盖相邻按钮。
var label := Label.new()
label.name = name
label.text = I18nScript.t(text)
label.position = position
label.size = size
label.horizontal_alignment = align
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.clip_text = true
if _should_use_title_font(name, font_size):
style_title_label(label, font_size, color, outline, outline_color)
else:
style_label(label, font_size, color, outline, outline_color)
parent.add_child(label)
return label
func add_texture_button(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, text: String, callback: Callable, font_size: int) -> Control:
# 不是 Godot 原生 TextureButton旧 UI 需要“贴图 + 文本 + 透明命中层 + 按压缩放”的组合。
# 返回 holder调用方可用 set_texture_button_enabled 同时控制贴图和 hit.disabled。
var holder := Control.new()
holder.name = name
holder.position = position
holder.size = size
holder.pivot_offset = size * 0.5
holder.mouse_filter = Control.MOUSE_FILTER_STOP
parent.add_child(holder)
add_texture(holder, "Art", texture, Vector2.ZERO, size)
if not text.is_empty():
var label := add_panel_label(holder, "Label", text, Vector2.ZERO, size, font_size, Color.html("#59120c"), 0, Color.TRANSPARENT)
style_button_label(label, font_size, Color.html("#59120c"), 0, Color.TRANSPARENT)
var hit := Button.new()
# Button 放最后,确保它覆盖 Art/LabelArt/Label 忽略鼠标,命中不受层级影响。
hit.name = "Hit"
hit.position = Vector2.ZERO
hit.size = size
hit.text = ""
hit.focus_mode = Control.FOCUS_NONE
UiMotionScript.apply_empty_button_style(hit)
hit.pressed.connect(callback)
hit.button_down.connect(func() -> void: UiMotionScript.play_press_scale(_owner, holder))
hit.button_up.connect(func() -> void: UiMotionScript.play_release_scale(_owner, holder))
holder.add_child(hit)
return holder
func add_item_slot(parent: Control, name: String, item_id: int, count: int, position: Vector2, size := Vector2(76.0, 76.0)) -> Control:
# 物品格统一解析 item_id -> icon_id -> Texture2D。
# 有些面板传入的是 icon_id有些传入 item_id因此保留 get_item_icon_id 回调做兼容。
var holder := Control.new()
holder.name = name
holder.position = position
holder.size = size
holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(holder)
add_texture(holder, "Bg", _texture_provider.common("item_bg"), Vector2.ZERO, size)
var icon_id := item_id
if _get_item_icon_id.is_valid():
icon_id = int(_get_item_icon_id.call(item_id))
var icon_texture: Texture2D = _texture_provider.common("item_bg")
if _texture_provider != null and _texture_provider.has_method("item_icon"):
icon_texture = _texture_provider.item_icon(icon_id)
elif _get_item_icon_texture.is_valid():
icon_texture = _get_item_icon_texture.call(icon_id) as Texture2D
add_texture(holder, "Icon", icon_texture, Vector2(7.0, 6.0), size - Vector2(14.0, 18.0), TextureRect.STRETCH_KEEP_ASPECT_CENTERED)
add_panel_label(holder, "Num", "x%s" % NumberFormatterScript.compact(count), Vector2(4.0, size.y - 25.0), Vector2(size.x - 8.0, 24.0), 18, Color.html("#b36011"), 1, Color.html("#ffe59d"), HORIZONTAL_ALIGNMENT_RIGHT)
return holder
func style_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void:
# 字体和描边必须统一,否则中文标签和原版按钮文字会出现明显视觉差异。
_style_label_with_font(label, _body_font, size, color, outline, outline_color)
func style_title_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void:
_style_label_with_font(label, _title_font, size, color, outline, outline_color)
func style_button_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void:
_style_label_with_font(label, _button_font, size, color, outline, outline_color)
func _style_label_with_font(label: Label, font: Font, size: int, color: Color, outline: int, outline_color: Color) -> void:
if label == null:
return
var target_font := font if font != null else _font
if target_font != null:
label.add_theme_font_override("font", target_font)
label.add_theme_font_size_override("font_size", size)
label.add_theme_color_override("font_color", color)
label.add_theme_constant_override("outline_size", outline)
label.add_theme_color_override("font_outline_color", outline_color)
func _measure_text_width(text: String, font_size: int, font: Font = null) -> float:
var target_font := font if font != null else _body_font
if target_font == null:
return float(text.length() * font_size) * 0.55
return target_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x
func _font_variation(base_font: Font, glyph_spacing: int, space_spacing: int) -> Font:
if base_font == null:
return null
var variation := FontVariation.new()
variation.base_font = base_font
variation.spacing_glyph = glyph_spacing
variation.spacing_space = space_spacing
return variation
func _should_use_title_font(label_name: String, font_size: int) -> bool:
if font_size < 24:
return false
var lowered := label_name.to_lower()
return lowered.contains("title")
func apply_empty_button_style(button: Button) -> void:
UiMotionScript.apply_empty_button_style(button)